diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 5df9d7f2..8ab592df 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -15,7 +15,7 @@ on: push: branches: [main, 'android/**'] pull_request: - paths: ['apps/android/**', '.github/workflows/android.yml'] + paths: ['apps/android/**', 'openaide-rs/android-ssh/**', 'openaide-rs/remote-client/**', '.github/workflows/android.yml'] permissions: contents: read @@ -56,12 +56,34 @@ jobs: with: name: android-browser-diagnostics path: test-results/ + version: + # The version the APK reports, stamped into the Linux App Server test builds carry. + if: ${{ !inputs.release }} + runs-on: ubuntu-24.04 + timeout-minutes: 5 + outputs: + version: ${{ steps.read.outputs.version }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + - id: read + run: echo "version=$(node -p 'require("./package.json").version')" >> "$GITHUB_OUTPUT" + linux-app-server: + # A release has already built this artifact in the calling workflow. + if: ${{ !inputs.release }} + needs: version + uses: ./.github/workflows/build-app-server-target.yml + with: + target: linux-x64-musl + version: ${{ needs.version.outputs.version }} apk: # Test builds carry the App Server built from the same commit, so local work # can be tried before the version is published. Releases download the published one. - needs: runtime + needs: [runtime, linux-app-server] + # The Linux App Server job is skipped in releases, which would skip this job too. + if: ${{ !cancelled() && needs.runtime.result == 'success' && (inputs.release || needs.linux-app-server.result == 'success') }} runs-on: ubuntu-24.04 - timeout-minutes: 20 + # Includes building the SSH client library and its bindings. + timeout-minutes: 35 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 @@ -72,6 +94,26 @@ jobs: - name: Carry the runtime in test builds if: ${{ !inputs.release }} run: echo "BUNDLED_RUNTIME=-PopenaideBundledRuntime=$RUNNER_TEMP/runtime/openaide-app-server-android-arm64.tar.gz" >> "$GITHUB_ENV" + # SSH Computers upload the Linux App Server only if its SHA-256 is built in. The release + # pins the binary published beside this APK; test builds carry the one from this commit. + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 + with: + name: app-server-linux-x64-musl + path: ${{ runner.temp }}/linux-app-server + - name: Pin the Linux App Server for SSH Computers + if: ${{ inputs.release }} + run: | + sha="$(openssl dgst -sha256 -r "$RUNNER_TEMP/linux-app-server/openaide-app-server" | cut -d ' ' -f 1)" + test "${#sha}" -eq 64 + echo "SSH_APP_SERVER=-PopenaideSshAppServerSha256=$sha" >> "$GITHUB_ENV" + # The SSH client is a Rust library the APK carries. + - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c + with: + toolchain: 1.97.1 + targets: aarch64-linux-android + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 + with: + shared-key: android-ssh - uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 with: distribution: temurin @@ -82,11 +124,17 @@ jobs: - uses: android-actions/setup-android@be39fa834029ff78f1a44aa3bb0819b8fc2bd8fd with: packages: platform-tools - - run: sdkmanager 'platforms;android-35' 'build-tools;35.0.0' + - run: sdkmanager 'platforms;android-35' 'build-tools;35.0.0' 'ndk;27.2.12479018' - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 with: node-version-file: .node-version cache: npm + - name: Carry the Linux App Server in test builds + if: ${{ !inputs.release }} + run: | + version="$(node -p 'require("./package.json").version')" + node scripts/package-app-server-archive.mjs "$RUNNER_TEMP/linux-app-server/openaide-app-server" "$version" "$RUNNER_TEMP/ssh-archive" + echo "SSH_APP_SERVER=-PopenaideSshAppServerArchive=$RUNNER_TEMP/ssh-archive/openaide-app-server-linux-x64-$version.tar.gz" >> "$GITHUB_ENV" - name: Build the Frontend shipped in the APK run: | npm ci @@ -122,11 +170,12 @@ jobs: # Release workflow adds the version. - name: Check and build APKs run: | - gradle -p apps/android --no-daemon $BUNDLED_RUNTIME testDebugUnitTest lintDebug assembleDebug assembleDebugAndroidTest + gradle -p apps/android --no-daemon $BUNDLED_RUNTIME $SSH_APP_SERVER testDebugUnitTest lintDebug assembleDebug assembleDebugAndroidTest + cp apps/android/app/build/outputs/apk/debug/app-debug.apk apps/android/build/openaide-android-debug.apk - name: Build user APK if: steps.key.outputs.staged == 'true' run: | - gradle -p apps/android --no-daemon $BUNDLED_RUNTIME assembleRelease + gradle -p apps/android --no-daemon $BUNDLED_RUNTIME $SSH_APP_SERVER assembleRelease cp apps/android/app/build/outputs/apk/release/app-release.apk apps/android/build/openaide-android.apk - name: Remove the signing key if: always() && steps.key.outputs.staged == 'true' diff --git a/.github/workflows/build-vsix.yml b/.github/workflows/build-vsix.yml index bc110dba..55bda760 100644 --- a/.github/workflows/build-vsix.yml +++ b/.github/workflows/build-vsix.yml @@ -184,7 +184,7 @@ jobs: desktop-darwin-arm64: if: needs.prepare.outputs.desktop_darwin_arm64 == 'true' - needs: [prepare, server-darwin-arm64] + needs: [prepare, server-darwin-arm64, server-linux-x64-musl] uses: ./.github/workflows/package-desktop-target.yml with: target: darwin-arm64 diff --git a/.github/workflows/package-desktop-target.yml b/.github/workflows/package-desktop-target.yml index c3e93c1d..a2738ff5 100644 --- a/.github/workflows/package-desktop-target.yml +++ b/.github/workflows/package-desktop-target.yml @@ -80,11 +80,17 @@ jobs: with: name: app-server-${{ inputs.target }} path: app-server + # Windows bundles it for WSL; every Desktop pins its checksum for SSH Computers. - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 - if: inputs.target == 'win32-x64' with: name: app-server-linux-x64-musl path: wsl-app-server + - name: Pin the Linux App Server for SSH Computers + shell: bash + run: | + sha="$(openssl dgst -sha256 -r wsl-app-server/openaide-app-server | cut -d ' ' -f 1)" + test "${#sha}" -eq 64 + echo "OPENAIDE_SSH_APP_SERVER_SHA256=$sha" >> "$GITHUB_ENV" - name: Restore App Server executable permission if: runner.os != 'Windows' run: chmod +x "app-server/${{ steps.target.outputs.binary }}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 98637a6a..a58c24f5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -63,7 +63,8 @@ jobs: enable_windows_updates: true android-artifacts: - needs: validate + # The APK pins the Linux App Server that SSH Computers run, built by artifacts. + needs: [validate, artifacts] uses: ./.github/workflows/android.yml secrets: inherit with: diff --git a/CONTEXT.md b/CONTEXT.md index eb869421..a2066f31 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -295,6 +295,10 @@ _Avoid_: Host as a product term A phone or computer other than the one running the App Server whose App Shell connects to that App Server as a regular client. It is trusted because it was paired, has full access, and stays trusted until any client removes it. _Avoid_: Account, login, linked phone +**Terminal**: +The user's own interactive shell on the App Server's computer, opened from a Project or Task and starting in its folder. It belongs to no Task, outlives the clients viewing it, and ends when closed, when its shell exits, or when App Server stops. +_Avoid_: Console, Agent terminal, treating a Terminal as Task context + **Pairing Code**: A short plain-text code, also drawn as a QR code, that adds a Remote Device. Either a trusted client shows a single-use code the new device reads, or the new device shows a code a trusted client reads and confirms. _Avoid_: Password, token, invite link diff --git a/Cargo.lock b/Cargo.lock index 30221e17..05af0a5e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -15,7 +15,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" dependencies = [ "crypto-common 0.1.7", - "generic-array", + "generic-array 0.14.7", +] + +[[package]] +name = "aead" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99" +dependencies = [ + "crypto-common 0.2.2", + "inout 0.2.5", ] [[package]] @@ -25,24 +35,51 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", - "cipher", + "cipher 0.4.4", "cpufeatures 0.2.17", ] +[[package]] +name = "aes" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32" +dependencies = [ + "cipher 0.5.2", + "cpubits", + "cpufeatures 0.3.1", + "zeroize", +] + [[package]] name = "aes-gcm" version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" dependencies = [ - "aead", - "aes", - "cipher", - "ctr", - "ghash", + "aead 0.5.2", + "aes 0.8.4", + "cipher 0.4.4", + "ctr 0.9.2", + "ghash 0.5.1", "subtle", ] +[[package]] +name = "aes-gcm" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f2b8006a0c83f52b62ba44a97b58bf76fe2f70a329e588f67f89691d93d498f" +dependencies = [ + "aead 0.6.1", + "aes 0.9.3", + "cipher 0.5.2", + "ctr 0.10.1", + "ctutils", + "ghash 0.6.0", + "zeroize", +] + [[package]] name = "agent-client-protocol" version = "3.4.0" @@ -106,6 +143,12 @@ dependencies = [ "libc", ] +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + [[package]] name = "anyhow" version = "1.0.102" @@ -121,12 +164,77 @@ dependencies = [ "rustversion", ] +[[package]] +name = "argon2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "134c52ddac6d63c576bef8168db10c83c49c26444ecbc68060fef078925a901c" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.3.1", + "password-hash", +] + [[package]] name = "arrayvec" version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" +[[package]] +name = "askama" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6024d73179f43f15ccd2b881bfea6fee7f3a46ec53f33b52210dea749ebebaa4" +dependencies = [ + "askama_macros", + "itoa", + "percent-encoding", + "serde", + "serde_json", +] + +[[package]] +name = "askama_derive" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071ee5ebf2138e3ad180e0aacf6940c2cab5e6d8333741d9925c7bee2b153f39" +dependencies = [ + "askama_parser", + "basic-toml", + "glob", + "memchr", + "proc-macro2", + "quote", + "rustc-hash", + "serde", + "serde_derive", + "syn 3.0.0", +] + +[[package]] +name = "askama_macros" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "643e1c7cbb6aec1d920332fe51a7c0d8219e273dcb8602db03f5263e4d16487b" +dependencies = [ + "askama_derive", +] + +[[package]] +name = "askama_parser" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c5ae75772275d268b03ab8bdccdd12117b6169ee23256942b34e46c9f476583" +dependencies = [ + "rustc-hash", + "serde", + "serde_derive", + "unicode-ident", + "winnow", +] + [[package]] name = "async-channel" version = "2.5.0" @@ -280,6 +388,26 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "basic-toml" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" +dependencies = [ + "serde", +] + +[[package]] +name = "bcrypt-pbkdf" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "144e573728da132683b9488acd528274c790e07fc06ff81ee29f9d8f8b1041e0" +dependencies = [ + "blowfish", + "pbkdf2", + "sha2 0.11.0", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -292,6 +420,15 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +[[package]] +name = "blake2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b5d4d889834ee8ecfc0f8426ad30faf7cdcb10f741a8e6d7224d95325479f6f" +dependencies = [ + "digest 0.11.3", +] + [[package]] name = "blake3" version = "1.8.7" @@ -305,11 +442,30 @@ dependencies = [ "cpufeatures 0.3.1", ] +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array 0.14.7", +] + [[package]] name = "block-buffer" version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", + "zeroize", +] + +[[package]] +name = "block-padding" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "710f1dd022ef4e93f8a438b4ba958de7f64308434fa6a87104481645cc30068b" dependencies = [ "hybrid-array", ] @@ -336,6 +492,16 @@ dependencies = [ "piper", ] +[[package]] +name = "blowfish" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62ce3946557b35e71d1bbe07ec385073ce9eda05043f95de134eb578fcf1a298" +dependencies = [ + "byteorder", + "cipher 0.5.2", +] + [[package]] name = "bs58" version = "0.5.1" @@ -367,6 +533,12 @@ version = "1.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "byteorder-lite" version = "0.1.0" @@ -379,6 +551,48 @@ version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +[[package]] +name = "camino" +version = "1.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbad30e4b4c14a39e3cc8aed085a12a327257c316619c93581e017bc52be591" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0061da739915fae12ea00e16397555ed4371a6bb285431aab930f61b0aa4ba" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "cargo_metadata" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef987d17b0a113becdd19d3d0022d04d7ef41f9efe4f3fb63ac44ba61df3ade9" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.21", +] + +[[package]] +name = "cbc" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896" +dependencies = [ + "cipher 0.5.2", +] + [[package]] name = "cc" version = "1.2.62" @@ -422,8 +636,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", + "cipher 0.5.2", "cpufeatures 0.3.1", "rand_core", + "zeroize", ] [[package]] @@ -433,8 +649,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ "iana-time-zone", + "js-sys", "num-traits", "serde", + "wasm-bindgen", "windows-link", ] @@ -445,9 +663,60 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ "crypto-common 0.1.7", - "inout", + "inout 0.1.4", +] + +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", + "inout 0.2.5", + "zeroize", +] + +[[package]] +name = "clap" +version = "4.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fb99565819980999fb7b4a1796046a5c949e6d4ff132cf5fadf5a641e20d776" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" +dependencies = [ + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32f2392eae7f16557a3d727ef3a12e57b2b2ca6f98566a5f4fb41ffe305df077" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", ] +[[package]] +name = "clap_lex" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" + [[package]] name = "cmov" version = "0.5.4" @@ -535,6 +804,12 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -587,13 +862,30 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crypto-bigint" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a52aa3fcda4e6302a9f48734f234d35d4721b96f8fe07d073f07ce9df4f0271" +dependencies = [ + "cpubits", + "ctutils", + "getrandom 0.4.2", + "hybrid-array", + "num-traits", + "rand_core", + "serdect", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ - "generic-array", + "generic-array 0.14.7", "typenum", ] @@ -603,17 +895,37 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ + "getrandom 0.4.2", "hybrid-array", "rand_core", ] +[[package]] +name = "crypto-primes" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3633a51a39c69ebbaa4feaa694bd83d241e4093901c84a0963b19d9bb3f0cf8f" +dependencies = [ + "crypto-bigint", + "rand_core", +] + [[package]] name = "ctr" version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" dependencies = [ - "cipher", + "cipher 0.4.4", +] + +[[package]] +name = "ctr" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21" +dependencies = [ + "cipher 0.5.2", ] [[package]] @@ -634,6 +946,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03bb0e1cc970d482d121d9a1744999169b69a07470b3d644a7894e53fcaf4574" dependencies = [ "cmov", + "subtle", ] [[package]] @@ -645,7 +958,7 @@ dependencies = [ "cfg-if", "cpufeatures 0.3.1", "curve25519-dalek-derive", - "digest", + "digest 0.11.3", "fiat-crypto", "rand_core", "rustc_version", @@ -725,6 +1038,17 @@ dependencies = [ "syn 3.0.0", ] +[[package]] +name = "delegate" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "780eb241654bf097afb00fc5f054a09b687dad862e485fdcf8399bb056565370" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "der" version = "0.8.2" @@ -769,21 +1093,41 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "des" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916a94e407b54f9034d71dd748234cd1e516ced6284009906ae246f177eafe5a" +dependencies = [ + "cipher 0.5.2", +] + [[package]] name = "diatomic-waker" version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab03c107fafeb3ee9f5925686dbb7a73bc76e3932abb0d2b365cb64b169cf04c" +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + [[package]] name = "digest" version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ - "block-buffer", + "block-buffer 0.12.1", "const-oid", "crypto-common 0.2.2", + "ctutils", ] [[package]] @@ -838,6 +1182,21 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "ecdsa" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0681a4fc24c767085329728d8dfba959af91228aa4610cca4f8ce317ba46ae0" +dependencies = [ + "der", + "digest 0.11.3", + "elliptic-curve", + "rfc6979", + "signature", + "spki", + "zeroize", +] + [[package]] name = "ed25519" version = "3.0.0" @@ -859,12 +1218,34 @@ dependencies = [ "ed25519", "rand_core", "serde", - "sha2", + "sha2 0.11.0", "signature", "subtle", "zeroize", ] +[[package]] +name = "elliptic-curve" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d65aa39b3a5c1c9c1b745c9a019234bb7a21b77abcb4f4d266d706e2d577d65" +dependencies = [ + "base16ct", + "crypto-bigint", + "crypto-common 0.2.2", + "digest 0.11.3", + "ff", + "group", + "hkdf", + "hybrid-array", + "pem-rfc7468", + "pkcs8", + "rand_core", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "embed-resource" version = "3.0.11" @@ -902,6 +1283,18 @@ dependencies = [ "syn 3.0.0", ] +[[package]] +name = "enum_dispatch" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa18ce2bc66555b3218614519ac839ddb759a7d6720732f979ef8d13be147ecd" +dependencies = [ + "once_cell", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -953,6 +1346,16 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "ff" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1f686ab92a9fb0eaf188f6c6c87b89490baa6fdb0db4544ba4dc47f7942489f" +dependencies = [ + "rand_core", + "subtle", +] + [[package]] name = "fiat-crypto" version = "0.3.0" @@ -970,6 +1373,16 @@ dependencies = [ "winapi", ] +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -1020,6 +1433,15 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs-err" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5c95b673b8f6f7235229ae11c5642d81b04c2e64c1e2fb417bc0cf73ca45f29" +dependencies = [ + "autocfg", +] + [[package]] name = "fs2" version = "0.4.3" @@ -1191,6 +1613,17 @@ dependencies = [ "version_check", ] +[[package]] +name = "generic-array" +version = "1.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "337d46834ee672ab3e48caca2cb0c78cc174fb12b3a68d0d88f99a0519a5e36e" +dependencies = [ + "generic-array 0.14.7", + "rustversion", + "typenum", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -1227,7 +1660,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" dependencies = [ "opaque-debug", - "polyval", + "polyval 0.6.2", +] + +[[package]] +name = "ghash" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eecf2d5dc9b66b732b97707a0210906b1d30523eb773193ab777c0c84b3e8d5" +dependencies = [ + "polyval 0.7.3", + "zeroize", ] [[package]] @@ -1240,6 +1683,12 @@ dependencies = [ "weezl", ] +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + [[package]] name = "globset" version = "0.4.19" @@ -1265,6 +1714,28 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "goblin" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b363a30c165f666402fe6a3024d3bec7ebc898f96a4a23bd1c99f8dbf3f4f47" +dependencies = [ + "log", + "plain", + "scroll", +] + +[[package]] +name = "group" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd1a1c7a5206c5b7a3f5a0d7ccd3ff85d0c8f5133d62a02680255b0004af5f4" +dependencies = [ + "ff", + "rand_core", + "subtle", +] + [[package]] name = "h2" version = "0.4.20" @@ -1321,10 +1792,34 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" [[package]] -name = "hex" -version = "0.4.3" +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hex-literal" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e712f64ec3850b98572bffac52e2c6f282b29fe6c5fa6d42334b30be438d95c1" + +[[package]] +name = "hkdf" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] [[package]] name = "http" @@ -1377,7 +1872,10 @@ version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" dependencies = [ + "ctutils", + "subtle", "typenum", + "zeroize", ] [[package]] @@ -1700,7 +2198,17 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" dependencies = [ - "generic-array", + "generic-array 0.14.7", +] + +[[package]] +name = "inout" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b5cf4372d022092674a83b8febf5865f7a573cc0c5baec5e49c879b6a20978f" +dependencies = [ + "block-padding", + "hybrid-array", ] [[package]] @@ -1987,6 +2495,26 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "keccak" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8f198d1db720e4940b5a493201d199d9f24f568f8f746bd13706243a2f71598" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", +] + +[[package]] +name = "kem" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01737161ba802849cfd486b5bd209d38ba4943494c249a8126005170c7621edd" +dependencies = [ + "crypto-common 0.2.2", + "rand_core", +] + [[package]] name = "kqueue" version = "1.2.0" @@ -2086,12 +2614,24 @@ dependencies = [ "regex-automata", ] +[[package]] +name = "md5" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebb8d8732c6a6df3d8f032a82911cfc747e00efb95cc46e8d0acd5b5b88570c" + [[package]] name = "memchr" version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2124,6 +2664,32 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "ml-kem" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9ba01ebcf90f6aff8e9b9f55bb69b19e6250e4ade5787d0040eaa27dd226238" +dependencies = [ + "hybrid-array", + "kem", + "module-lattice", + "pkcs8", + "rand_core", + "sha3", + "shake", +] + +[[package]] +name = "module-lattice" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c61b87c9683ab7cb1c6871d261ad5479b6b10ceb52c4352aaca3b5d35a8febe" +dependencies = [ + "ctutils", + "hybrid-array", + "num-traits", +] + [[package]] name = "moxcms" version = "0.8.1" @@ -2410,6 +2976,16 @@ dependencies = [ "libc", ] +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "noq" version = "1.3.0" @@ -2438,7 +3014,7 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c1e5b6fe668491eca022f745a0a9402585626c73a7b839b3424ace15d6a9c8f" dependencies = [ - "aes-gcm", + "aes-gcm 0.10.3", "bytes", "derive_more", "enum-assoc", @@ -2518,12 +3094,33 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "num-bigint" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93e7820bc0a80a0238e650327316f929ba18d5be054b647490a3a6a339f3e7c0" +dependencies = [ + "num-integer", + "num-traits", + "rand", + "rand_core", +] + [[package]] name = "num-conv" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2657,6 +3254,19 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "openaide-android-ssh" +version = "0.0.0" +dependencies = [ + "openaide-remote-client", + "russh", + "serde_json", + "tempfile", + "thiserror 2.0.21", + "tokio", + "uniffi", +] + [[package]] name = "openaide-app-server" version = "0.0.0" @@ -2682,7 +3292,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", - "sha2", + "sha2 0.11.0", "shell-words", "signal-hook", "tauri-winres", @@ -2708,10 +3318,14 @@ name = "openaide-remote-client" version = "0.0.0" dependencies = [ "data-encoding", + "flate2", "iroh", "openaide-app-server-protocol", "serde", "serde_json", + "sha2 0.10.9", + "tar", + "tempfile", "thiserror 2.0.21", "tokio", ] @@ -2722,6 +3336,67 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "p256" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2c9239b2dbc807adbbe147e8cf72ea7450c3a0aabe62cb8e75ff4ec22e1f72a" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primefield", + "primeorder", + "sha2 0.11.0", +] + +[[package]] +name = "p384" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d17b851e6b3e378ab4ecb07fa2ed23f4d15f075735f8fec9fa1e7bdce5f8301f" +dependencies = [ + "ecdsa", + "elliptic-curve", + "fiat-crypto", + "primefield", + "primeorder", + "sha2 0.11.0", +] + +[[package]] +name = "p521" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ad64cc32c2dc466317c12ee5853e61f159f9eab1fe7efade0395dc2e7b43449" +dependencies = [ + "base16ct", + "ecdsa", + "elliptic-curve", + "primefield", + "primeorder", + "sha2 0.11.0", +] + +[[package]] +name = "pageant" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0786409b5aaa884655c9b4065681db0835db0cc185a6fb19fb17a5d38656866" +dependencies = [ + "base16ct", + "byteorder", + "bytes", + "delegate", + "futures", + "log", + "rand", + "sha2 0.11.0", + "thiserror 2.0.21", + "tokio", + "windows", + "windows-strings", +] + [[package]] name = "papaya" version = "0.2.5" @@ -2738,12 +3413,31 @@ version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" +[[package]] +name = "password-hash" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aab41826031698d6ffcd9cff78ef56ef998e39dc7e5067cdfebe373842d4723b" +dependencies = [ + "phc", +] + [[package]] name = "paste" version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pbkdf2" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112d82ceb8c5bf524d9af484d4e4970c9fd5a0cc15ba14ad93dccd28873b0629" +dependencies = [ + "digest 0.11.3", + "hmac", +] + [[package]] name = "pem-rfc7468" version = "1.0.0" @@ -2769,6 +3463,16 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "phc" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44dc769b75f93afdddd8c7fa12d685292ddeff1e66f7f0f3a234cf1818afe892" +dependencies = [ + "base64ct", + "ctutils", +] + [[package]] name = "pin-project" version = "1.1.13" @@ -2806,6 +3510,33 @@ dependencies = [ "futures-io", ] +[[package]] +name = "pkcs1" +version = "0.8.0-rc.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "986d2e952779af96ea048f160fd9194e1751b4faea78bcf3ceb456efe008088e" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkcs5" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63d440a804ec8d6fafbb6b84471e013286658d373248927692ab3366686220ca" +dependencies = [ + "aes 0.9.3", + "aes-gcm 0.11.1", + "cbc", + "der", + "pbkdf2", + "rand_core", + "scrypt", + "sha2 0.11.0", + "spki", +] + [[package]] name = "pkcs8" version = "0.11.0" @@ -2813,6 +3544,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" dependencies = [ "der", + "pkcs5", + "rand_core", "spki", ] @@ -2822,6 +3555,12 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + [[package]] name = "plist" version = "1.10.1" @@ -2862,6 +3601,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "poly1305" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2d0073b297041425c7c3df6eb4792d598a15323fe63346852b092eca02904c" +dependencies = [ + "cpufeatures 0.3.1", + "universal-hash 0.6.1", + "zeroize", +] + [[package]] name = "polyval" version = "0.6.2" @@ -2871,7 +3621,19 @@ dependencies = [ "cfg-if", "cpufeatures 0.2.17", "opaque-debug", - "universal-hash", + "universal-hash 0.5.1", +] + +[[package]] +name = "polyval" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0fa31d631f2b2cb2a544d0aa321ce847a94764d701ca2becc411138b93d49cd" +dependencies = [ + "cpubits", + "cpufeatures 0.3.1", + "universal-hash 0.6.1", + "zeroize", ] [[package]] @@ -2982,6 +3744,33 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "primefield" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c555a6e4eb7d4e158fcb028c835c3b8642206ddc279b5c6b202ef9a8bdb592f4" +dependencies = [ + "crypto-bigint", + "crypto-common 0.2.2", + "ff", + "rand_core", + "subtle", + "zeroize", +] + +[[package]] +name = "primeorder" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c9f42978c78a00e3d68f69fc03e57a234debae69da4020a4fb588fcdcd07b06" +dependencies = [ + "elliptic-curve", + "once_cell", + "primefield", + "serdect", + "wnaf", +] + [[package]] name = "proc-macro-crate" version = "3.5.0" @@ -3155,17 +3944,140 @@ dependencies = [ ] [[package]] -name = "ring" -version = "0.17.14" +name = "rfc6979" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4a459cddafb3fe76b31fd8f1108007566c40301feb64dc7b54656eb7388172b" +dependencies = [ + "crypto-bigint", + "hmac", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rsa" +version = "0.10.0-rc.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30b2aa4ba0d89f73d1e332df05be0eeab8840351c36ca5654341dfdb57bb3caf" +dependencies = [ + "const-oid", + "crypto-bigint", + "crypto-primes", + "digest 0.11.3", + "pkcs1", + "pkcs8", + "rand_core", + "sha2 0.11.0", + "signature", + "spki", + "zeroize", +] + +[[package]] +name = "russh" +version = "0.64.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba61e87b9ec9a39a59a6bbed4c0b8ff7fb07073405b24a767a1f2c8560ac8f58" +dependencies = [ + "aes 0.9.3", + "bitflags 2.11.1", + "block-padding", + "byteorder", + "bytes", + "cbc", + "cipher 0.5.2", + "crypto-bigint", + "ctr 0.10.1", + "curve25519-dalek", + "data-encoding", + "delegate", + "der", + "digest 0.11.3", + "ecdsa", + "ed25519-dalek", + "elliptic-curve", + "enum_dispatch", + "futures", + "generic-array 1.4.5", + "getrandom 0.4.2", + "ghash 0.6.0", + "hex-literal", + "hmac", + "inout 0.2.5", + "keccak", + "log", + "md5", + "ml-kem", + "module-lattice", + "num-bigint", + "p256", + "p384", + "p521", + "pageant", + "pbkdf2", + "pkcs1", + "pkcs5", + "pkcs8", + "polyval 0.7.3", + "rand", + "rand_core", + "ring", + "rsa", + "russh-cryptovec", + "russh-util", + "salsa20", + "scrypt", + "sec1", + "sha1", + "sha2 0.11.0", + "sha3", + "signature", + "spki", + "ssh-encoding", + "ssh-key", + "subtle", + "thiserror 2.0.21", + "tokio", + "typenum", + "universal-hash 0.6.1", + "zeroize", +] + +[[package]] +name = "russh-cryptovec" +version = "0.62.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +checksum = "3aec6cb630dbe85d72ffd7bcd95f07e1bd69f9f270ee8adfa1afe443a6331438" dependencies = [ - "cc", - "cfg-if", - "getrandom 0.2.17", - "libc", - "untrusted", - "windows-sys 0.52.0", + "log", + "nix 0.31.3", + "ssh-encoding", + "windows-sys 0.61.2", +] + +[[package]] +name = "russh-util" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668424a5dde0bcb45b55ba7de8476b93831b4aa2fa6947e145f3b053e22c60b6" +dependencies = [ + "chrono", + "tokio", + "wasm-bindgen", + "wasm-bindgen-futures", ] [[package]] @@ -3283,6 +4195,16 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "salsa20" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f874456e72520ff1375a06c588eaf074b0f01f9e9e1aada45bd9b7954a6e42c" +dependencies = [ + "cfg-if", + "cipher 0.5.2", +] + [[package]] name = "same-file" version = "1.0.6" @@ -3344,6 +4266,52 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" +[[package]] +name = "scroll" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ab8598aa408498679922eff7fa985c25d58a90771bd6be794434c5277eab1a6" +dependencies = [ + "scroll_derive", +] + +[[package]] +name = "scroll_derive" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1783eabc414609e28a5ba76aee5ddd52199f7107a0b24c2e9746a1ecc34a683d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "scrypt" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87af57419b594aa23fa95f09f0e06d80d84ba01c26148c43844cad6ff4485f0" +dependencies = [ + "cfg-if", + "pbkdf2", + "salsa20", + "sha2 0.11.0", +] + +[[package]] +name = "sec1" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d" +dependencies = [ + "base16ct", + "ctutils", + "der", + "hybrid-array", + "subtle", + "zeroize", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -3378,6 +4346,10 @@ name = "semver" version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] [[package]] name = "send_wrapper" @@ -3525,12 +4497,34 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + [[package]] name = "sha1_smol" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + [[package]] name = "sha2" version = "0.11.0" @@ -3539,7 +4533,29 @@ checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" dependencies = [ "cfg-if", "cpufeatures 0.3.1", - "digest", + "digest 0.11.3", +] + +[[package]] +name = "sha3" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc9bad02c26382724b2d2692c6f179285e4b54eeecd7968f52a50059c3c11759" +dependencies = [ + "digest 0.11.3", + "keccak", + "sponge-cursor", +] + +[[package]] +name = "shake" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09057cb2149ad4cbd2da1e26b351f9a4c354219421229c69c3063e6f61947c4a" +dependencies = [ + "digest 0.11.3", + "keccak", + "sponge-cursor", ] [[package]] @@ -3599,6 +4615,7 @@ version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" dependencies = [ + "digest 0.11.3", "rand_core", ] @@ -3633,6 +4650,12 @@ dependencies = [ "bitflags 2.11.1", ] +[[package]] +name = "siphasher" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" + [[package]] name = "slab" version = "0.4.12" @@ -3645,6 +4668,12 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "smawk" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8e2fb0f499abb4d162f2bedad68f5ef91a1682b5a03596ddb67efd37768d100" + [[package]] name = "socket2" version = "0.6.5" @@ -3688,12 +4717,83 @@ dependencies = [ "der", ] +[[package]] +name = "sponge-cursor" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a0219bd7d979d58245a4f41f695e1ac9f8befdffadd7f61f1bae9e39abc6620" + +[[package]] +name = "ssh-cipher" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d801accda99469cde6d73da741422610fdf6508a72d9a69d1b55cb241c720597" +dependencies = [ + "aead 0.6.1", + "aes 0.9.3", + "aes-gcm 0.11.1", + "chacha20", + "cipher 0.5.2", + "ctutils", + "des", + "poly1305", + "ssh-encoding", + "zeroize", +] + +[[package]] +name = "ssh-encoding" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b54d0ed0498daf3f78d82e00e28c8eec9d75a067c4cfbcc7a0f7d0f4077749e" +dependencies = [ + "base64ct", + "bytes", + "crypto-bigint", + "ctutils", + "digest 0.11.3", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "ssh-key" +version = "0.7.0-rc.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9a32fae177b74a22aa9c5b01bf7e68b33545be32d9e381e248058d2adc15ce3" +dependencies = [ + "argon2", + "bcrypt-pbkdf", + "ctutils", + "ed25519-dalek", + "hex", + "hmac", + "p256", + "p384", + "p521", + "rand_core", + "rsa", + "sec1", + "sha1", + "sha2 0.11.0", + "signature", + "ssh-cipher", + "ssh-encoding", + "zeroize", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "strsim" version = "0.11.1" @@ -3790,6 +4890,17 @@ dependencies = [ "libc", ] +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "tauri-winres" version = "0.3.6" @@ -3823,6 +4934,16 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "textwrap" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ecfad6c3abc80a577f2b91c1e412ee57e7a060d430b553c1b0c940974ebcd49" +dependencies = [ + "smawk", + "unicode-width", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -4220,12 +5341,139 @@ version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "unicode-xid" version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "uniffi" +version = "0.32.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76407f5f396a2c949a069eff4a9fca9ce462410eb872bffef4c2b7b89804a77a" +dependencies = [ + "anyhow", + "camino", + "cargo_metadata", + "clap", + "uniffi_bindgen", + "uniffi_core", + "uniffi_macros", + "uniffi_pipeline", +] + +[[package]] +name = "uniffi_bindgen" +version = "0.32.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3df3ced8f0eda3de99f6d50820e8628f443da8e4f447f477d1403ceba29dacbd" +dependencies = [ + "anyhow", + "askama", + "camino", + "cargo_metadata", + "fs-err", + "glob", + "goblin", + "heck", + "indexmap 2.14.0", + "once_cell", + "serde", + "tempfile", + "textwrap", + "toml", + "uniffi_internal_macros", + "uniffi_meta", + "uniffi_pipeline", + "uniffi_udl", +] + +[[package]] +name = "uniffi_core" +version = "0.32.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7530ae8efeaaa488622865966763fe0294832779fff80508c36d69c6a874a6a" +dependencies = [ + "anyhow", + "bytes", + "once_cell", + "static_assertions", +] + +[[package]] +name = "uniffi_internal_macros" +version = "0.32.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f4bad017164375d99450d70495014810d15a84ba9da6ed14b9628b1424223ba" +dependencies = [ + "anyhow", + "indexmap 2.14.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "uniffi_macros" +version = "0.32.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b855a58da153739ce6e863f6e296edc3716a093768c4d3d957f17f5e4317c60" +dependencies = [ + "camino", + "fs-err", + "once_cell", + "proc-macro2", + "quote", + "serde", + "syn 2.0.117", + "toml", + "uniffi_meta", +] + +[[package]] +name = "uniffi_meta" +version = "0.32.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af0c88664a9cb9559856f5a250283c9708923b303170e2e5c7db8f4ad65e9459" +dependencies = [ + "anyhow", + "siphasher", + "uniffi_internal_macros", + "uniffi_pipeline", +] + +[[package]] +name = "uniffi_pipeline" +version = "0.32.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d00f5c044b4a3dca0b1226c92ddaeea626f337470e22180dd182160c87a06b0b" +dependencies = [ + "anyhow", + "heck", + "indexmap 2.14.0", + "tempfile", + "uniffi_internal_macros", +] + +[[package]] +name = "uniffi_udl" +version = "0.32.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55dfd9c778d6b39cb5acd541d9ff63431974035d2bad83c897b766a6ee3152ac" +dependencies = [ + "anyhow", + "textwrap", + "uniffi_meta", + "weedle2", +] + [[package]] name = "universal-hash" version = "0.5.1" @@ -4236,6 +5484,16 @@ dependencies = [ "subtle", ] +[[package]] +name = "universal-hash" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96" +dependencies = [ + "crypto-common 0.2.2", + "ctutils", +] + [[package]] name = "unsafe-libyaml" version = "0.2.11" @@ -4494,6 +5752,15 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "weedle2" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998d2c24ec099a87daf9467808859f9d82b61f1d9c9701251aea037f514eae0e" +dependencies = [ + "nom", +] + [[package]] name = "weezl" version = "0.1.12" @@ -5026,6 +6293,18 @@ dependencies = [ "windows-core", ] +[[package]] +name = "wnaf" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "795ca18b3fdb5e62bf982199278341ddcf7ebf7d32e25e212ad05d496e95f6fa" +dependencies = [ + "ff", + "group", + "hybrid-array", + "primefield", +] + [[package]] name = "writeable" version = "0.6.4" @@ -5051,6 +6330,16 @@ dependencies = [ "web-sys", ] +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + [[package]] name = "xml-rs" version = "0.8.29" diff --git a/Cargo.toml b/Cargo.toml index 1e60837c..2736e79c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["openaide-rs/app-server", "openaide-rs/app-server-protocol", "openaide-rs/remote-client"] +members = ["openaide-rs/android-ssh", "openaide-rs/app-server", "openaide-rs/app-server-protocol", "openaide-rs/remote-client"] resolver = "2" [workspace.package] @@ -9,6 +9,7 @@ publish = false [workspace.dependencies] openaide-app-server-protocol = { path = "openaide-rs/app-server-protocol" } +openaide-remote-client = { path = "openaide-rs/remote-client", default-features = false } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" serde_yaml = "0.9" diff --git a/apps/android/README.md b/apps/android/README.md index bb06997a..dff11854 100644 --- a/apps/android/README.md +++ b/apps/android/README.md @@ -52,6 +52,32 @@ makes up to three attempts within 24 seconds before reporting the computer as unreachable. While hidden it makes one attempt per request and no more. The computer must be running OpenAIDE; the phone and the computer must speak the same App Server Protocol major version. +## SSH computers + +An **SSH computer** is a Linux x64 computer the phone signs in to over SSH and starts +OpenAIDE on, as Desktop does ([ADR-0069](../../docs/adr/0069-ssh-computers-are-started-like-termux.md)). +Nothing needs to be installed there first. Add one from **Settings → Remote → Add SSH +computer** or the first setup screen, as `user@host` or `user@host:port`. The phone +shows the computer's host key fingerprint once to accept; that key is then pinned, +and a different one blocks the connection until the computer is forgotten and added +again. The phone has its own SSH key, an ed25519 key kept encrypted with Android +Keystore like its pairing key, and shows its public key to add to +`~/.ssh/authorized_keys` there. Instead, a password may be entered once and the phone +adds the key itself; the password is not kept. Jump hosts and SSH configuration files +are not used. + +The SSH client is the `openaide-rs/android-ssh` Rust library, which runs the same +launcher as Desktop. On first use of a version, the phone downloads the canonical +`openaide-app-server-linux-x64-VERSION.tar.gz` release, keeps the binary only if its +SHA-256 is the one built into the APK, and uploads it over SSH. The App Server there +stays running under its keeper while agents work, so work continues with the phone +locked or the app closed; **Stop** in Settings ends the keeper. When the computer runs +an App Server too old for this app, the phone asks before replacing it, because +replacing it interrupts every other app using it. The WebView loads the Frontend from +`127.0.0.1:5477`; the gateway carries each request through the SSH forward with the +App Server's token, which never reaches the WebView. The background service watches +that App Server's Tasks for notifications, as it watches local ones. + Opening the local workspace runs the App Server in Termux in its handoff mode: it attaches to the server already serving the state or launches one, and prints its loopback address and per-process token. The app keeps that token in memory, never @@ -161,8 +187,9 @@ the runtime directory so replacing runtime files does not delete task history. ## Build and validation -Use JDK 17, Gradle 8.13 and Android SDK 35. The APK ships the shared Frontend, so -build it first: +Use JDK 17, Gradle 8.13, Android SDK 35 and NDK 27.2.12479018, and Rust with the +`aarch64-linux-android` target: the build compiles the SSH library and generates its +Kotlin bindings. The APK ships the shared Frontend, so build it first: ```sh npm run build:typescript-deps && npm run build --workspace openaide-frontend @@ -171,6 +198,12 @@ gradle -p apps/android testDebugUnitTest lintDebug assembleDebug The APK is built for ARM64 only. The iroh library adds about 14 MB. +A release pins the published Linux App Server with `-PopenaideSshAppServerSha256=HEX`; +a build with neither property leaves SSH computers out. CI test builds carry the +Linux App Server built from the same commit, about 48 MB more. A local test build can +carry an archive too, with the version its binary reports: +`gradle -PopenaideSshAppServerArchive=/path/to/openaide-app-server-linux-x64.tar.gz -PopenaideSshAppServerVersion=0.0.0 assembleDebug`. + CI separately cross-compiles the App Server with the Android NDK, builds the shared Frontend, runs existing Web Shell tests and packages the Termux runtime, which is the App Server alone. diff --git a/apps/android/app/build.gradle b/apps/android/app/build.gradle index edcecf05..ae9bc2b9 100644 --- a/apps/android/app/build.gradle +++ b/apps/android/app/build.gradle @@ -26,6 +26,20 @@ def bundledRuntime = project.hasProperty('openaideBundledRuntime') ? file(projec if (bundledRuntime != null && !bundledRuntime.isFile()) throw new GradleException('Bundled runtime archive not found') def bundledRuntimeDigest = bundledRuntime == null ? '' : java.security.MessageDigest.getInstance('SHA-256').digest(bundledRuntime.bytes).encodeHex().toString() def runtimeAssets = layout.buildDirectory.dir('generated/runtime-assets') +// SSH Computers (ADR-0069) upload the Linux App Server only if its SHA-256 is the one built in. +// Releases pin the published binary: -PopenaideSshAppServerSha256=HEX. A test build may instead +// carry an archive and the version its binary reports: +// gradle -PopenaideSshAppServerArchive=/path/to/openaide-app-server-linux-x64.tar.gz -PopenaideSshAppServerVersion=0.0.0 assembleDebug +def sshArchive = project.hasProperty('openaideSshAppServerArchive') ? file(project.property('openaideSshAppServerArchive')) : null +if (sshArchive != null && !sshArchive.isFile()) throw new GradleException('SSH App Server archive not found') +def sshBinaryDigest = { File archive -> + def binary = tarTree(resources.gzip(archive)).matching { include 'openaide-app-server/openaide-app-server' }.singleFile + java.security.MessageDigest.getInstance('SHA-256').digest(binary.bytes).encodeHex().toString() +} +def sshAppServerSha256 = sshArchive != null ? sshBinaryDigest(sshArchive) : (project.findProperty('openaideSshAppServerSha256') ?: '') as String +if (!(sshAppServerSha256 ==~ /([0-9a-f]{64})?/)) throw new GradleException('Invalid SSH App Server SHA-256') +def sshAppServerVersion = (project.findProperty('openaideSshAppServerVersion') ?: appVersion) as String +def sshAssets = layout.buildDirectory.dir('generated/ssh-assets') if (versionParts[1] > 999 || versionParts[2] > 999 || appVersionCode < 1 || appVersionCode > 2100000000) throw new GradleException('Android version is out of range') android { @@ -54,10 +68,15 @@ android { buildConfigField 'int', 'PROTOCOL_MAJOR', "${protocolMajor}" buildConfigField 'String', 'CLAUDE_ACP_VERSION', "\"${claudeAcpVersion}\"" buildConfigField 'String', 'BUNDLED_RUNTIME_SHA256', "\"${bundledRuntimeDigest}\"" + buildConfigField 'String', 'SSH_APP_SERVER_SHA256', "\"${sshAppServerSha256}\"" + buildConfigField 'String', 'SSH_APP_SERVER_VERSION', "\"${sshAppServerVersion}\"" + buildConfigField 'boolean', 'SSH_APP_SERVER_BUNDLED', "${sshArchive != null}" // The iroh library is about 14 MB per architecture; phones are ARM64. ndk { abiFilters 'arm64-v8a' } } - sourceSets { main { assets.srcDirs frontendAssets, runtimeAssets } } + // The SSH library is built with this NDK; the Termux runtime uses the same one. + ndkVersion '27.2.12479018' + sourceSets { main { assets.srcDirs frontendAssets, runtimeAssets, sshAssets } } kotlinOptions { jvmTarget = '17' } packaging { resources { excludes += ['/META-INF/{AL2.0,LGPL2.1}', 'darwin-aarch64/**', 'win32-x86-64/**', 'linux-*/**'] } @@ -102,12 +121,78 @@ def copyBundledRuntime = tasks.register('copyBundledRuntime') { if (bundledRuntime != null) copy { from bundledRuntime; into target; rename { 'openaide-app-server-android-arm64.bin' } } } } +// Always runs, like copyBundledRuntime; named .bin so packaging keeps the archive as it is. +def copySshAppServer = tasks.register('copySshAppServer') { + def target = sshAssets.map { it.dir('ssh') } + inputs.property('digest', sshAppServerSha256) + outputs.dir(target) + doLast { + delete target + mkdir target + if (sshArchive != null) copy { from sshArchive; into target; rename { 'openaide-app-server-linux-x64.bin' } } + } +} + +/** + * Builds the SSH client library (openaide-rs/android-ssh) for phones and generates its + * Kotlin bindings from that same library, so the two cannot disagree. + */ +abstract class BuildSshLibrary extends DefaultTask { + @javax.inject.Inject abstract ExecOperations getExec() + @InputFiles @PathSensitive(PathSensitivity.RELATIVE) abstract ConfigurableFileCollection getSources() + @Input abstract Property getNdkDirectory() + @Internal abstract DirectoryProperty getWorkspace() + @OutputDirectory abstract DirectoryProperty getJniLibs() + @OutputDirectory abstract DirectoryProperty getBindings() + + @TaskAction void build() { + def root = workspace.get().asFile + def os = System.getProperty('os.name').toLowerCase() + def host = os.contains('linux') ? 'linux-x86_64' : os.contains('mac') ? 'darwin-x86_64' : null + if (host == null) throw new GradleException('Build the Android SSH library on Linux or macOS') + def toolchain = new File(ndkDirectory.get(), "toolchains/llvm/prebuilt/${host}/bin") + if (!toolchain.isDirectory()) throw new GradleException('Install the Android NDK named by ndkVersion') + def clang = new File(toolchain, 'aarch64-linux-android26-clang').absolutePath + exec.exec { + workingDir root + environment 'CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER', clang + environment 'CC_aarch64_linux_android', clang + environment 'AR_aarch64_linux_android', new File(toolchain, 'llvm-ar').absolutePath + commandLine 'cargo', 'build', '--locked', '--release', '-p', 'openaide-android-ssh', '--lib', '--target', 'aarch64-linux-android' + } + def targetDirectory = System.getenv('CARGO_TARGET_DIR') ? new File(System.getenv('CARGO_TARGET_DIR')) : new File(root, 'target') + def library = new File(targetDirectory, 'aarch64-linux-android/release/libopenaide_android_ssh.so') + def abi = jniLibs.get().dir('arm64-v8a').asFile + jniLibs.get().asFile.deleteDir() + bindings.get().asFile.deleteDir() + abi.mkdirs() + java.nio.file.Files.copy(library.toPath(), new File(abi, library.name).toPath()) + exec.exec { + workingDir root + commandLine 'cargo', 'run', '--locked', '-q', '-p', 'openaide-android-ssh', '--bin', 'uniffi-bindgen', '--', + 'generate', '--library', library.absolutePath, '--language', 'kotlin', '--no-format', '--out-dir', bindings.get().asFile.absolutePath + } + } +} +def repository = rootProject.file('../..') +def buildSshLibrary = tasks.register('buildSshLibrary', BuildSshLibrary) { + workspace.set(repository) + sources.from(new File(repository, 'Cargo.lock'), new File(repository, 'Cargo.toml')) + sources.from(fileTree(new File(repository, 'openaide-rs/android-ssh')) { exclude 'target/**' }) + sources.from(fileTree(new File(repository, 'openaide-rs/remote-client')) { exclude 'target/**' }) + ndkDirectory.set(androidComponents.sdkComponents.ndkDirectory.map { it.asFile.absolutePath }) + jniLibs.set(layout.buildDirectory.dir('generated/ssh/jniLibs')) + bindings.set(layout.buildDirectory.dir('generated/ssh/kotlin')) +} androidComponents.onVariants(androidComponents.selector().all()) { variant -> + variant.sources.jniLibs.addGeneratedSourceDirectory(buildSshLibrary) { it.jniLibs } + // Kotlin compiles the .kt files of Java source directories too. + variant.sources.java.addGeneratedSourceDirectory(buildSshLibrary) { it.bindings } afterEvaluate { - tasks.named("merge${variant.name.capitalize()}Assets") { dependsOn copyFrontend, copyBundledRuntime } + tasks.named("merge${variant.name.capitalize()}Assets") { dependsOn copyFrontend, copyBundledRuntime, copySshAppServer } // Release builds run the "vital" lint tasks, which read the same assets. def lintTasks = ['generate%sLintReportModel', 'lintAnalyze%s', 'generate%sLintVitalReportModel', 'lintVitalAnalyze%s'].collect { String.format(it, variant.name.capitalize()) } - tasks.matching { it.name in lintTasks }.configureEach { dependsOn copyFrontend, copyBundledRuntime } + tasks.matching { it.name in lintTasks }.configureEach { dependsOn copyFrontend, copyBundledRuntime, copySshAppServer } } } dependencies { diff --git a/apps/android/app/src/androidTest/java/io/openaide/android/DeviceChecks.java b/apps/android/app/src/androidTest/java/io/openaide/android/DeviceChecks.java index c78e9edc..75fe1018 100644 --- a/apps/android/app/src/androidTest/java/io/openaide/android/DeviceChecks.java +++ b/apps/android/app/src/androidTest/java/io/openaide/android/DeviceChecks.java @@ -250,7 +250,7 @@ public void testNativeStatusReadsTheLocalAppServer() throws Exception { public void testGatewayRefusesRequestsWithoutTheShellsCookie() throws Exception { var context = getInstrumentation().getTargetContext(); LocalServer.status(context); - assertTrue(WorkspaceGateway.INSTANCE.start(context, true)); + assertTrue(WorkspaceGateway.INSTANCE.start(context, ConnectionProfile.Kind.LOCAL)); java.net.HttpURLConnection connection = (java.net.HttpURLConnection) new java.net.URL(ConnectionProfile.local().endpoint + "__openaide-app-server/probe").openConnection(); try { assertEquals(403, connection.getResponseCode()); } diff --git a/apps/android/app/src/main/assets/setup/app.js b/apps/android/app/src/main/assets/setup/app.js index c4a346b7..a2aea1ab 100644 --- a/apps/android/app/src/main/assets/setup/app.js +++ b/apps/android/app/src/main/assets/setup/app.js @@ -11,6 +11,7 @@ let firstRun = false; const icons = { phone: '', computer: '', + server: '', qr: '', keyboard: '', battery: '', @@ -35,12 +36,18 @@ function item(kind, glyph, title, detail, target, value) { return ``; } function choice(glyph, title, detail, destination) { return item('choice', glyph, title, detail, destination); } +// A choice that starts a native flow, such as adding an SSH computer, instead of opening a screen. +function choiceAction(glyph, title, detail, action) { return item('row choice', glyph, title, detail, action); } function row(glyph, title, action, value = '', detail = '') { return item('row', glyph, title, detail, action, value); } // The place in use: named like the others, but not something to tap. function place(glyph, title, detail) { return `
${icon(glyph)}${title}${detail}In use
`; } function computerRow(computer) { return ``; } +function sshRow(computer) { + if (computer.current) return place('server', escape(computer.name), `SSH · ${escape(computer.destination)}`); + return ``; +} // Background information is prose behind a question placed where it comes up, not text on the page. // Shared by every screen that explains a remote connection; names the public service it may use (ADR 0062). @@ -130,21 +137,28 @@ function render() { page.innerHTML = heading('Where will your
agents work?') + '
' + choice('computer', 'Remote computer', 'Pair with a code', 'remote') + + (state.sshAvailable ? choiceAction('server', 'SSH computer', 'A Linux computer you sign in to', 'ssh_add') : '') + choice('phone', 'This phone', 'Runs in Termux', 'local') + '
' + hint(); } else if (screen === 'settings') { const remote = state.remote; const computers = state.computers || []; + const ssh = state.sshComputers || []; // One list of places with the one in use marked; adding a computer is a separate act below it. page.innerHTML = `

${remote ? escape(state.computer || 'Remote computer') : 'This phone'}

${state.connected ? '

Connected

' : '

Not connected

'}` + '

Where agents work

' + (remote ? row('phone', 'This phone', 'local_screen', '', 'Runs in Termux') : place('phone', 'This phone', 'Runs in Termux')) + computers.map(computer => computer.current ? place('computer', escape(computer.name || 'Remote computer'), 'Remote computer') : computerRow(computer)).join('') + + ssh.map(sshRow).join('') + '
' + row('qr', computers.length ? 'Add another computer' : 'Add a computer', 'remote_screen', '', 'Pair with a code') + + (state.sshAvailable ? row('server', 'Add an SSH computer', 'ssh_add', '', 'A Linux computer you sign in to') : '') + '
' - + (remote ? row('bell', 'Task notifications', 'background_screen', state.background ? 'On' : 'Off') + // A paired computer's Tasks only notify here; work on this phone or an SSH Computer + // is also held by this phone in the background. + + (remote && !ssh.some(computer => computer.current) + ? row('bell', 'Task notifications', 'background_screen', state.background ? 'On' : 'Off') : row('battery', 'Background work', 'background_screen', state.background ? 'On' : 'Off')) + row('wrench', 'Advanced', 'advanced_screen') + '
' + (computers.length ? hint() : ''); diff --git a/apps/android/app/src/main/java/io/openaide/android/AndroidDiagnostics.java b/apps/android/app/src/main/java/io/openaide/android/AndroidDiagnostics.java index cd14b10f..0ddba9d2 100644 --- a/apps/android/app/src/main/java/io/openaide/android/AndroidDiagnostics.java +++ b/apps/android/app/src/main/java/io/openaide/android/AndroidDiagnostics.java @@ -41,6 +41,21 @@ void record(String event, String outcome, int count, long durationMs, java.util. } } + /** + * Records an event whose fields another layer chose, such as the SSH library's + * stage, outcome, class, and duration. Those fields are metadata only by contract. + */ + void recordFields(String event, String fields) { + try { + JSONObject entry = new JSONObject(fields); + entry.put("timestamp", System.currentTimeMillis()).put("event", event); + LOG.append(entry.toString()); + Log.i("OpenAIDE", entry.toString()); + } catch (JSONException error) { + Log.w("OpenAIDE", "diagnostics_serialization_failed"); + } + } + String snapshot() { return LOG.snapshot(); } diff --git a/apps/android/app/src/main/java/io/openaide/android/BackgroundService.java b/apps/android/app/src/main/java/io/openaide/android/BackgroundService.java index 774b3127..f413bf98 100644 --- a/apps/android/app/src/main/java/io/openaide/android/BackgroundService.java +++ b/apps/android/app/src/main/java/io/openaide/android/BackgroundService.java @@ -22,12 +22,12 @@ /** * The ongoing notification that keeps OpenAIDE working while it is off the screen. * - * For the workspace on this phone it keeps the phone awake while a Task works and - * reads Task state from the local App Server. For a paired computer it does not stop - * when idle: it keeps a {@link RemoteAttention} watch, so Task notifications still - * arrive while the WebView is paused, and it also watches this phone's App Server if - * that is already running, so the phone's own Tasks keep it awake and notify too - * ({@link BackgroundSources}). The selected workspace decides the mode on every start, + * For the workspace on this phone or on an SSH Computer it keeps the phone awake while + * a Task works and reads Task state from the App Server this shell holds. For a paired + * computer it does not stop when idle: it keeps a {@link RemoteAttention} watch, so Task + * notifications still arrive while the WebView is paused, and it also watches this phone's + * App Server if that is already running, so the phone's own Tasks keep it awake and notify + * too ({@link BackgroundSources}). The selected workspace decides the mode on every start, * so a restart by the system resumes the same one. */ public final class BackgroundService extends Service { @@ -65,7 +65,9 @@ public final class BackgroundService extends Service { manager.createNotificationChannel(new NotificationChannel("background", "Background work", NotificationManager.IMPORTANCE_LOW)); manager.createNotificationChannel(new NotificationChannel("completion", "Work updates", NotificationManager.IMPORTANCE_DEFAULT)); protection = new WorkProtection(getSystemService(PowerManager.class), "OpenAIDE:active-work"); - startForeground(1, notification(Boolean.TRUE.equals(pairedWorkspace()) ? "Connecting to your computer…" : "Checking local work…", "background")); + Boolean paired = pairedWorkspace(); + startForeground(1, notification(Boolean.TRUE.equals(paired) ? "Connecting to your computer…" + : Boolean.FALSE.equals(paired) && sshWorkspace() ? "Checking work on your SSH computer…" : "Checking local work…", "background")); diagnostics.record("background_work", "started", 0, 0); } @@ -97,6 +99,12 @@ private Boolean pairedWorkspace() { catch (RuntimeException unavailable) { return null; } } + /** Whether the selected workspace is an SSH Computer; false when the choice cannot be read. */ + private boolean sshWorkspace() { + try { return new ConnectionStore(this).usesSsh(); } + catch (RuntimeException unavailable) { return false; } + } + /** Follows the selected workspace; false when the service stopped instead. */ private boolean chooseMode() { Boolean paired = pairedWorkspace(); @@ -124,7 +132,7 @@ private boolean chooseMode() { // The phone's reads start over, so a long paired stretch is not taken for a lost server. policy.observe(0, SystemClock.elapsedRealtime()); } - diagnostics.record("background_work", paired ? "paired" : "local", 0, 0, Map.of("switched", switched)); + diagnostics.record("background_work", paired ? "paired" : sshWorkspace() ? "ssh" : "local", 0, 0, Map.of("switched", switched)); return true; } @@ -156,8 +164,13 @@ private void poll() { worker.execute(() -> { ServerStatus status = null; boolean running = true; - // The phone's App Server is started only while it is the workspace in use. - try { status = paired ? LocalServer.statusIfRunning() : LocalServer.status(this); running = status != null; } + // The phone's App Server is started only while it is the workspace in use; while an + // SSH Computer is in use, the App Server this shell holds is the one there. + try { + status = paired ? LocalServer.statusIfRunning() + : new ConnectionStore(this).usesSsh() ? SshServer.status(this) : LocalServer.status(this); + running = status != null; + } catch (Exception ignored) { } ServerStatus result = status; boolean serverRunning = running; diff --git a/apps/android/app/src/main/java/io/openaide/android/ConnectionController.java b/apps/android/app/src/main/java/io/openaide/android/ConnectionController.java index ad269ed8..2ac023d5 100644 --- a/apps/android/app/src/main/java/io/openaide/android/ConnectionController.java +++ b/apps/android/app/src/main/java/io/openaide/android/ConnectionController.java @@ -34,6 +34,8 @@ interface View { private boolean disposed; private boolean recheckOnReturn; private String notice = ""; + /** The outcome of the last Stop of an SSH Computer; the Remote section shows it. */ + private String sshNotice = ""; private long operationStarted; private int operationSequence; private String operationName; @@ -51,7 +53,8 @@ interface View { void receive(JSONObject message) { if (disposed) return; String command = message.optString("action"); - if (!localTools && !java.util.Arrays.asList("state", "close", "pair_setup", "app_settings", "diagnostics").contains(command)) { + if (!localTools && !java.util.Arrays.asList("state", "close", "pair_setup", "app_settings", "diagnostics", + "ssh_add", "use_ssh", "forget_ssh", "stop_ssh", "update_ssh", "background").contains(command)) { notice = "Switch to this phone before managing local tools."; emit(); return; @@ -73,7 +76,12 @@ void switchComputer(JSONObject message, ComputerSwitch.Reply reply) { String target = action.equals("use_computer") ? message.optString("id") : null; String operationId = message.has("operationId") ? message.optString("operationId") : null; if (action.equals("forget_computer")) { forget(message.optString("id"), operationId, reply); return; } - switcher.request(selected == null ? null : selected.id, target, operationId, reply); + // An SSH Computer in use is a source of its own, so returning to this phone is a switch. + // TODO(ssh-computers): switch to SSH Computers through this switch too (ADR-0070), so + // "use_ssh" connects before it replaces the page and the switcher lists them. + SshComputers.Computer ssh = store.usesSsh() ? store.sshComputer() : null; + String source = selected != null ? selected.id : ssh != null ? "ssh:" + ssh.id : null; + switcher.request(source, target, operationId, reply); } /** Forgets a paired computer; the one in use is left through the switch first, and forgotten only once it took effect. */ @@ -95,6 +103,7 @@ private void action(JSONObject message) throws Exception { if (action.equals("state")) { emit(); return; } if (busy && !action.equals("close")) return; notice = ""; + sshNotice = ""; switch (action) { case "close": view.close(); break; case "state": emit(); break; @@ -104,6 +113,31 @@ private void action(JSONObject message) throws Exception { case "pair": pair(message.optString("code")); break; case "join": join(); break; case "join_stop": RemotePairing.stopJoin(); joinCode = null; emit(); break; + case "ssh_add": SshSetup.start(activity, this::changed); break; + case "use_ssh": + if (!new ConnectionStore(this).selectSsh(message.optString("id"))) { emit(); break; } + RemoteNode.INSTANCE.disconnect(); + SshServer.reset(); + diagnostics.record("connection_ssh_selected", "selected", 0, 0); + changed(); + break; + case "forget_ssh": { + ConnectionStore store = new ConnectionStore(this); + SshComputers.Computer selected = store.sshComputer(); + boolean current = selected != null && selected.id.equals(message.optString("id")); + store.forgetSsh(message.optString("id")); + diagnostics.record("connection_ssh_forgotten", current ? "current" : "other", 0, 0); + if (current) { SshServer.reset(); changed(); } else emit(); + break; + } + case "stop_ssh": stopSsh(message.optString("id")); break; + case "update_ssh": + // The Frontend showed what the replacement interrupts; reconnecting replaces it. + if (!new ConnectionStore(this).usesSsh()) { emit(); break; } + diagnostics.record("connection_ssh_update", "confirmed", 0, 0); + SshServer.update(); + changed(); + break; case "install": install(); break; case "termux": launchTermux(); break; case "get_termux": open(new Intent(Intent.ACTION_VIEW, Uri.parse("https://github.com/termux/termux-app/releases"))); break; @@ -213,6 +247,23 @@ private void join() { }); } + private void stopSsh(String id) { + ConnectionStore store = new ConnectionStore(this); + SshComputers.Computer computer = SshComputers.find(store.sshComputers(), id); + if (computer == null) { emit(); return; } + begin("ssh_stop", "Stopping OpenAIDE on " + computer.name + "…"); + SshServer.stop(this, computer, store, outcome -> { + switch (outcome) { + case "stopped": sshNotice = "OpenAIDE on " + computer.name + " stops once no app is connected to it."; break; + case "notRunning": sshNotice = "OpenAIDE was not kept running on " + computer.name + "."; break; + case "stillRunning": sshNotice = "OpenAIDE on " + computer.name + " did not stop yet. Try again in a moment."; break; + default: sshNotice = SshServer.message(outcome); + } + end(java.util.Arrays.asList("stopped", "notRunning", "stillRunning").contains(outcome), ""); + return kotlin.Unit.INSTANCE; + }); + } + private void begin(String operation, String text) { busy = true; notice = text; @@ -237,12 +288,16 @@ private void emit() { var preferences = getSharedPreferences("connection", MODE_PRIVATE); ConnectionStore store = new ConnectionStore(this); ConnectionStore.PairedServer computer = store.pairedServer(); + SshComputers.Computer ssh = store.sshComputer(); PowerManager power = getSystemService(PowerManager.class); JSONObject state = new JSONObject().put("initial", activity.getIntent().getStringExtra("screen")) .put("connected", activity.getIntent().getBooleanExtra("connected", false)) - .put("remote", store.usesPaired()).put("paired", !store.pairedServers().isEmpty()) - .put("computer", computer == null ? "" : computer.name) + .put("remote", store.usesPaired() || ssh != null).put("paired", !store.pairedServers().isEmpty()) + .put("computer", ssh != null ? ssh.name : computer == null ? "" : computer.name) .put("computers", computers(store)) + .put("sshComputers", sshComputers(store)) + .put("ssh", sshStatus(ssh)) + .put("sshAvailable", !BuildConfig.SSH_APP_SERVER_SHA256.isEmpty()).put("sshNotice", sshNotice) .put("join", joinCode == null ? JSONObject.NULL : joinCode).put("busy", busy).put("notice", notice) .put("termux", installed("com.termux")).put("permission", checkSelfPermission(PERMISSION) == PackageManager.PERMISSION_GRANTED) .put("checks", checks == null ? JSONObject.NULL : checks) @@ -266,6 +321,25 @@ private static org.json.JSONArray computers(ConnectionStore store) throws org.js return computers; } + /** Every SSH Computer, with the one in use marked. */ + private static org.json.JSONArray sshComputers(ConnectionStore store) throws org.json.JSONException { + SshComputers.Computer selected = store.sshComputer(); + org.json.JSONArray computers = new org.json.JSONArray(); + for (SshComputers.Computer computer : store.sshComputers()) { + computers.put(new JSONObject().put("id", computer.id).put("name", computer.name) + .put("destination", computer.destination()).put("current", selected != null && selected.id.equals(computer.id))); + } + return computers; + } + + /** What the last launch learned about the App Server on the SSH Computer in use, or null. */ + private static Object sshStatus(SshComputers.Computer selected) throws org.json.JSONException { + SshServer.Status status = SshServer.status(); + if (selected == null || status == null) return JSONObject.NULL; + return new JSONObject().put("runningVersion", status.runningVersion) + .put("updateAvailable", status.updateAvailable).put("suggestLinger", status.suggestLinger); + } + private boolean installed(String name) { try { getPackageManager().getPackageInfo(name, 0); return true; } catch (PackageManager.NameNotFoundException error) { return false; } diff --git a/apps/android/app/src/main/java/io/openaide/android/ConnectionProfile.java b/apps/android/app/src/main/java/io/openaide/android/ConnectionProfile.java index 71ac80f9..3cdc1f15 100644 --- a/apps/android/app/src/main/java/io/openaide/android/ConnectionProfile.java +++ b/apps/android/app/src/main/java/io/openaide/android/ConnectionProfile.java @@ -2,24 +2,48 @@ import java.net.URI; -/** The origin the workspace WebView loads: the app's own gateway, one port per workspace. */ +/** The origin the workspace WebView loads: the app's own gateway, one port per kind of workspace. */ final class ConnectionProfile { /** Fixed so the bundled Frontend keeps one origin per workspace, and with it its stored drafts and preferences. */ static final int LOCAL_PORT = 5476; static final int PAIRED_PORT = 5475; + static final int SSH_PORT = 5477; + + /** Where the workspace's App Server runs, and so how the gateway reaches it. */ + enum Kind { + /** In Termux on this phone. */ + LOCAL(LOCAL_PORT), + /** On a paired computer, over iroh. */ + PAIRED(PAIRED_PORT), + /** On an SSH Computer this phone started it on (ADR-0069). */ + SSH(SSH_PORT); + + final int port; + Kind(int port) { this.port = port; } + } + final String endpoint; + final Kind kind; + /** The App Server in Termux, whose local tools this phone manages. */ final boolean local; - private ConnectionProfile(int port, boolean local) { - this.endpoint = "http://127.0.0.1:" + port + "/"; - this.local = local; + private ConnectionProfile(Kind kind) { + this.endpoint = "http://127.0.0.1:" + kind.port + "/"; + this.kind = kind; + this.local = kind == Kind.LOCAL; } /** The App Server in Termux. The gateway holds its token; the WebView holds no credential for it. */ - static ConnectionProfile local() { return new ConnectionProfile(LOCAL_PORT, true); } + static ConnectionProfile local() { return new ConnectionProfile(Kind.LOCAL); } /** A paired computer is trusted by key; the WebView holds no credential for it. */ - static ConnectionProfile paired() { return new ConnectionProfile(PAIRED_PORT, false); } + static ConnectionProfile paired() { return new ConnectionProfile(Kind.PAIRED); } + + /** An SSH Computer. The gateway holds the token of its App Server; the WebView holds none. */ + static ConnectionProfile ssh() { return new ConnectionProfile(Kind.SSH); } + + /** This shell holds the App Server as a client, so the background service watches its Tasks. */ + boolean held() { return kind != Kind.PAIRED; } boolean owns(String address) { try { diff --git a/apps/android/app/src/main/java/io/openaide/android/ConnectionStore.java b/apps/android/app/src/main/java/io/openaide/android/ConnectionStore.java index 17de951d..79b6ee02 100644 --- a/apps/android/app/src/main/java/io/openaide/android/ConnectionStore.java +++ b/apps/android/app/src/main/java/io/openaide/android/ConnectionStore.java @@ -34,10 +34,58 @@ static final class PairedServer { if (preferences.contains("password")) preferences.edit().remove("password").apply(); } - ConnectionProfile load() { return usesPaired() ? ConnectionProfile.paired() : ConnectionProfile.local(); } + ConnectionProfile load() { + if (usesSsh()) return ConnectionProfile.ssh(); + return usesPaired() ? ConnectionProfile.paired() : ConnectionProfile.local(); + } /** Whether the paired computer, rather than this phone, is the selected workspace. */ - boolean usesPaired() { return preferences.getBoolean("paired", false) && pairedServer() != null; } + boolean usesPaired() { return !usesSsh() && preferences.getBoolean("paired", false) && pairedServer() != null; } + + /** Whether an SSH Computer is the selected workspace. */ + boolean usesSsh() { return sshComputer() != null; } + + /** The SSH Computer selected for work, or null. */ + SshComputers.Computer sshComputer() { + String id = preferences.getString("ssh_id", ""); + return id.isEmpty() ? null : SshComputers.find(sshComputers(), id); + } + + /** Every SSH Computer this phone knows, in the order they were added. */ + java.util.List sshComputers() { return SshComputers.parse(preferences.getString("ssh_computers", "")); } + + /** Adding an SSH Computer remembers its accepted host key and selects it. */ + void saveSsh(SshComputers.Computer computer) { + preferences.edit().putString("ssh_computers", SshComputers.format(SshComputers.with(sshComputers(), computer))) + .putString("ssh_id", computer.id).putBoolean("paired", false).putBoolean("configured", true).apply(); + } + + /** Selects one of the SSH Computers; returns false for one this phone does not know. */ + boolean selectSsh(String id) { + if (SshComputers.find(sshComputers(), id) == null) return false; + preferences.edit().putString("ssh_id", id).putBoolean("paired", false).putBoolean("configured", true).apply(); + return true; + } + + /** Stops knowing an SSH Computer; forgetting the selected one leaves this phone as the place to work. */ + void forgetSsh(String id) { + SharedPreferences.Editor edit = preferences.edit().putString("ssh_computers", SshComputers.format(SshComputers.without(sshComputers(), id))); + if (id.equals(preferences.getString("ssh_id", ""))) edit.remove("ssh_id").putBoolean("paired", false); + edit.apply(); + } + + /** + * This phone's own SSH key: an ed25519 seed, made on first use and kept encrypted + * like the pairing key. Every SSH Computer is given the same public key. + */ + byte[] sshKey() { + String stored = preferences.getString("ssh_key", ""); + if (!stored.isEmpty()) return Base64.decode(decrypt(stored), Base64.NO_WRAP); + byte[] seed = new byte[32]; + new java.security.SecureRandom().nextBytes(seed); + preferences.edit().putString("ssh_key", encrypt(Base64.encodeToString(seed, Base64.NO_WRAP))).apply(); + return seed; + } /** The computer selected for remote work, or null. */ PairedServer pairedServer() { @@ -57,7 +105,7 @@ java.util.List pairedServers() { /** Pairing adds the computer, or renames it, and selects it. */ void savePaired(PairedServer server) { - preferences.edit().putString("servers", PairedServers.format(PairedServers.with(pairedServers(), server))) + preferences.edit().remove("ssh_id").putString("servers", PairedServers.format(PairedServers.with(pairedServers(), server))) .putString("server_id", server.id).putString("server_name", server.name) .putBoolean("paired", true).putBoolean("configured", true).apply(); } @@ -82,13 +130,13 @@ void forgetPaired(String id) { boolean selectPaired(String id) { PairedServer server = PairedServers.find(pairedServers(), id); if (server == null) return false; - preferences.edit().putString("server_id", server.id).putString("server_name", server.name) + preferences.edit().remove("ssh_id").putString("server_id", server.id).putString("server_name", server.name) .putBoolean("paired", true).putBoolean("configured", true).apply(); return true; } /** Keeps the selected computer remembered while work moves to this phone. */ - void selectLocal() { preferences.edit().putBoolean("paired", false).putBoolean("configured", true).apply(); } + void selectLocal() { preferences.edit().remove("ssh_id").putBoolean("paired", false).putBoolean("configured", true).apply(); } /** This phone's private key as a Remote Device, or null before the first pairing. */ byte[] deviceKey() { diff --git a/apps/android/app/src/main/java/io/openaide/android/MainActivity.java b/apps/android/app/src/main/java/io/openaide/android/MainActivity.java index b907e9d0..5f6e8f6e 100644 --- a/apps/android/app/src/main/java/io/openaide/android/MainActivity.java +++ b/apps/android/app/src/main/java/io/openaide/android/MainActivity.java @@ -228,12 +228,12 @@ private void openSetup(String screen) { } private void changeConnection() { - if (profile.local && browser != null) { + if (profile.held() && browser != null) { try { startService(new Intent(this, BackgroundService.class).putExtra("visible", false)); } catch (RuntimeException ignored) { } } // The watch of a computer that is no longer selected would report it as lost. - if (!profile.local) stopService(new Intent(this, BackgroundService.class)); + if (profile.kind == ConnectionProfile.Kind.PAIRED) stopService(new Intent(this, BackgroundService.class)); profile = connections.load(); credentialsUnavailable = false; showConnection(); @@ -242,7 +242,7 @@ private void changeConnection() { else openSetup("welcome"); } - /** Keeps local work running, or a paired computer's Task notifications arriving, while the app is hidden. */ + /** Keeps work on this phone or an SSH Computer running, or a paired computer's Task notifications arriving, while the app is hidden. */ private void startBackgroundWork() { if (!getSharedPreferences("connection", MODE_PRIVATE).getBoolean("background", true)) return; try { startForegroundService(new Intent(this, BackgroundService.class).putExtra("visible", visible)); } @@ -353,7 +353,8 @@ private void connect() { connect.setVisibility(View.GONE); status.setText("Opening your workspace…"); Log.i("OpenAIDE", "connection_start attempt=" + attempt); - if (!profile.local) { connectPaired(attempt, started); return; } + if (profile.kind == ConnectionProfile.Kind.PAIRED) { connectPaired(attempt, started); return; } + if (profile.kind == ConnectionProfile.Kind.SSH) { connectSsh(attempt, started); return; } status.setText("Starting your local workspace…"); LocalServer.connect(this, problem -> { if (attempt != generation || isDestroyed()) return; @@ -397,10 +398,53 @@ private void connectPaired(int attempt, long started) { }); } + /** The SSH library starts or attaches to the App Server there; its stages explain a slow first start. */ + private void connectSsh(int attempt, long started) { + SshComputers.Computer computer = connections.sshComputer(); + String name = computer == null ? "the computer" : computer.name; + status.setText("Connecting to " + name + "…"); + SshServer.connect(this, stage -> { + if (attempt == generation && !isDestroyed()) status.setText(sshStage(stage, name)); + return kotlin.Unit.INSTANCE; + }, (problem, reason) -> { + if (attempt != generation || isDestroyed()) return; + if (problem == null) { opened(attempt, started); return; } + fail(attempt, problem); + if ("update_required".equals(reason)) offerSshUpdate(name); + }); + } + + private static String sshStage(String stage, String name) { + switch (stage) { + case "fetch": return "Downloading OpenAIDE for " + name + "…"; + case "upload": return "Installing OpenAIDE on " + name + "…"; + case "start": return "Starting OpenAIDE on " + name + "…"; + default: return "Connecting to " + name + "…"; + } + } + + /** + * An App Server too old for this app runs there. Replacing it interrupts whoever + * else uses it, so it happens only after this warning (ADR-0069). + */ + private void offerSshUpdate(String name) { + String running = SshServer.requiredUpdate(); + new android.app.AlertDialog.Builder(this).setTitle("Update OpenAIDE on " + name + "?") + .setMessage(name + " runs OpenAIDE " + (running == null ? "" : running + " ") + "which this app cannot use. Updating restarts it there: " + + "agents working there stop, and other apps connected to it disconnect until they update too.") + .setPositiveButton("Update and restart", (dialog, which) -> { + diagnostics.record("connection_ssh_update", "confirmed", 0, 0); + SshServer.update(); + connect(); + }) + .setNegativeButton("Not now", (dialog, which) -> diagnostics.record("connection_ssh_update", "declined", 0, 0)) + .show(); + } + /** The computer refused this phone while the workspace was open; only the user can repair that. */ private void pairedConnectionRefused(RemoteNode.Failure failure) { runOnUiThread(() -> { - if (isDestroyed() || profile.local || browser == null) return; + if (isDestroyed() || profile.kind != ConnectionProfile.Kind.PAIRED || browser == null) return; connected = false; diagnostics.record("connection", "refused", 0, 0); showConnection(); @@ -531,7 +575,7 @@ private void showBrowser() { // The gateway serves only this WebView: the cookie is its proof on every request. android.webkit.CookieManager.getInstance().setCookie(profile.endpoint, GatewayHttp.COOKIE + "=" + WorkspaceGateway.INSTANCE.getToken() + "; Path=/; HttpOnly; SameSite=Strict"); - if (!profile.local) WorkspaceGateway.INSTANCE.onFailure(failure -> { pairedConnectionRefused(failure); return kotlin.Unit.INSTANCE; }); + if (profile.kind == ConnectionProfile.Kind.PAIRED) WorkspaceGateway.INSTANCE.onFailure(failure -> { pairedConnectionRefused(failure); return kotlin.Unit.INSTANCE; }); browser.loadUrl(profile.endpoint); } diff --git a/apps/android/app/src/main/java/io/openaide/android/SshComputers.java b/apps/android/app/src/main/java/io/openaide/android/SshComputers.java new file mode 100644 index 00000000..f7edf891 --- /dev/null +++ b/apps/android/app/src/main/java/io/openaide/android/SshComputers.java @@ -0,0 +1,147 @@ +package io.openaide.android; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +/** + * The SSH Computers this phone knows (ADR-0069), as stored text: one per line, + * tab-separated id, name, host, port, user, and the host key the user accepted. + * Kept free of Android so the rules can be tested. + */ +final class SshComputers { + private SshComputers() {} + + /** An SSH Computer as the user added it. The host key is public; nothing here is secret. */ + static final class Computer { + final String id; + final String name; + final String host; + final int port; + final String user; + /** The OpenSSH host key accepted on first connection; a different one blocks the connection. */ + final String hostKey; + + Computer(String id, String name, String host, int port, String user, String hostKey) { + this.id = id; + this.name = name; + this.host = host; + this.port = port; + this.user = user; + this.hostKey = hostKey; + } + + /** `user@host`, with the port only when it is not 22, as the user would type it. */ + String destination() { + String address = host.indexOf(':') >= 0 ? "[" + host + "]" : host; + return user + "@" + address + (port == 22 ? "" : ":" + port); + } + } + + static List parse(String stored) { + List computers = new ArrayList<>(); + for (String line : stored.split("\n")) { + String[] fields = line.split("\t", -1); + if (fields.length != 6) continue; + int port; + try { port = Integer.parseInt(fields[3]); } catch (NumberFormatException error) { continue; } + if (!validId(fields[0]) || !validHost(fields[2]) || !validPort(port) || !validUser(fields[4]) + || !validHostKey(fields[5]) || find(computers, fields[0]) != null) continue; + computers.add(new Computer(fields[0], fields[1], fields[2], port, fields[4], fields[5])); + } + return computers; + } + + static String format(List computers) { + StringBuilder stored = new StringBuilder(); + for (Computer computer : computers) { + if (stored.length() > 0) stored.append('\n'); + stored.append(computer.id).append('\t').append(name(computer.name)).append('\t').append(computer.host) + .append('\t').append(computer.port).append('\t').append(computer.user).append('\t').append(computer.hostKey); + } + return stored.toString(); + } + + /** Adding a computer again with the same id replaces it in place. */ + static List with(List computers, Computer computer) { + List next = without(computers, computer.id); + int index = indexOf(computers, computer.id); + next.add(index < 0 ? next.size() : index, computer); + return next; + } + + static List without(List computers, String id) { + List next = new ArrayList<>(computers); + int index = indexOf(next, id); + if (index >= 0) next.remove(index); + return next; + } + + static Computer find(List computers, String id) { + int index = indexOf(computers, id); + return index < 0 ? null : computers.get(index); + } + + /** A name the user typed, on one line and of a length Settings can show. */ + static String name(String typed) { + String name = typed == null ? "" : typed.replaceAll("[\\p{Cntrl}]", " ").trim(); + return name.length() > 64 ? name.substring(0, 64) : name; + } + + static boolean validId(String id) { return id != null && id.matches("[0-9a-f]{32}"); } + + /** A DNS name or an IP address; nothing a shell or URL could read as more. */ + static boolean validHost(String host) { + return host != null && host.length() <= 253 && host.matches("[A-Za-z0-9._:%-]+") && !host.startsWith("-"); + } + + /** A Linux user name as `useradd` accepts it. */ + static boolean validUser(String user) { + return user != null && user.length() <= 64 && user.matches("[A-Za-z0-9._][A-Za-z0-9._-]*\\$?"); + } + + static boolean validPort(int port) { return port > 0 && port < 65536; } + + /** One OpenSSH public key line, as the SSH library returns it. */ + static boolean validHostKey(String key) { + return key != null && key.length() < 16384 && key.matches("(ssh|ecdsa)-[A-Za-z0-9@.-]+ [A-Za-z0-9+/=]+( [^\\t\\n]*)?"); + } + + /** + * Reads what the user typed in Host: `host`, `user@host`, `host:port`, or + * `[v6]:port`. Returns null for anything else. + */ + static String[] parseDestination(String typed, String defaultUser) { + String text = typed == null ? "" : typed.trim(); + String user = defaultUser == null ? "" : defaultUser.trim(); + int at = text.lastIndexOf('@'); + if (at >= 0) { user = text.substring(0, at); text = text.substring(at + 1); } + String host = text; + int port = 22; + if (text.startsWith("[")) { + int end = text.indexOf(']'); + if (end < 0) return null; + host = text.substring(1, end); + String rest = text.substring(end + 1); + if (!rest.isEmpty()) { + if (!rest.startsWith(":")) return null; + port = port(rest.substring(1)); + } + } else if (text.indexOf(':') >= 0 && text.indexOf(':') == text.lastIndexOf(':')) { + host = text.substring(0, text.indexOf(':')); + port = port(text.substring(text.indexOf(':') + 1)); + } + host = host.toLowerCase(Locale.ROOT); + if (!validHost(host) || !validUser(user) || !validPort(port)) return null; + return new String[]{host, Integer.toString(port), user}; + } + + private static int port(String text) { + try { return Integer.parseInt(text); } catch (NumberFormatException error) { return -1; } + } + + private static int indexOf(List computers, String id) { + for (int index = 0; index < computers.size(); index++) if (computers.get(index).id.equals(id)) return index; + return -1; + } +} diff --git a/apps/android/app/src/main/kotlin/io/openaide/android/GatewayHttp.kt b/apps/android/app/src/main/kotlin/io/openaide/android/GatewayHttp.kt index e4684a6e..95b49d71 100644 --- a/apps/android/app/src/main/kotlin/io/openaide/android/GatewayHttp.kt +++ b/apps/android/app/src/main/kotlin/io/openaide/android/GatewayHttp.kt @@ -50,6 +50,8 @@ internal object GatewayHttp { head.method == "POST" && path.endsWith("/upload/chunk") -> "/upload/chunk" head.method == "POST" && path.endsWith("/upload") -> "/upload" head.method == "GET" && path.endsWith("/download") -> "/download" + // A Terminal byte stream (ADR-0071) beside the session socket. + head.method == "GET" && head.upgrade && path == "$PROXY_PREFIX/terminal" -> "/terminal" else -> "" } return "/probe$suffix$query" diff --git a/apps/android/app/src/main/kotlin/io/openaide/android/HeldServer.kt b/apps/android/app/src/main/kotlin/io/openaide/android/HeldServer.kt new file mode 100644 index 00000000..a61d0708 --- /dev/null +++ b/apps/android/app/src/main/kotlin/io/openaide/android/HeldServer.kt @@ -0,0 +1,263 @@ +package io.openaide.android + +import android.content.Context +import android.os.SystemClock +import java.io.IOException +import java.net.HttpURLConnection +import java.net.URL +import java.util.concurrent.CompletableFuture +import java.util.concurrent.Executors +import java.util.concurrent.ScheduledFuture +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger +import org.json.JSONArray +import org.json.JSONObject + +/** Open tasks of the App Server in use, as the background service needs them. */ +internal class ServerStatus( + @JvmField val active: Int, + @JvmField val waiting: Int, + /** The current Task Attention Event of each open task that has one. */ + @JvmField val attention: List, +) { + companion object { + /** 1 for a task that is working, 0 for one waiting on the user, -1 for one at rest, null when unknown. */ + @JvmStatic fun kind(status: String): Int? = when (status) { + "preparing", "starting", "running", "background", "stopping" -> 1 + "waiting" -> 0 + "idle", "interrupted", "failed", "completed" -> -1 + else -> null + } + } +} + +/** + * An App Server this shell holds as a client, as an App Shell on a computer holds its own. + * + * The first request starts or attaches to the server through [Launch], which hands back + * a loopback address and token. The shell then initializes as the mobile client and + * heartbeats, so the server keeps it counted for as long as the app's process lives and + * ends by its own last-client rule once the app is gone. A server that stops answering + * is forgotten, and the next request launches again. The token never reaches the + * WebView: [WorkspaceGateway] adds it to each request it carries. + * + * [LocalServer] holds the App Server in Termux and [SshServer] the one on an SSH + * Computer; only how the server is reached differs. + */ +internal class HeldServer( + /** Names this server's diagnostics, such as `local_server`. */ + private val name: String, + private val startWaitMs: Long, + private val launch: Launch, +) { + class Endpoint(val host: String, val port: Int, val path: String, val token: String) { + companion object { + /** A loopback App Server address and its token, or null for anything else. */ + fun of(endpointUrl: String, token: String): Endpoint? = try { + val url = URL(endpointUrl) + if (url.protocol != "http" || url.host != "127.0.0.1" || url.port <= 0 || token.isEmpty() || token.any { it.isWhitespace() }) null + else Endpoint(url.host, url.port, url.path.trimEnd('/'), token) + } catch (error: Exception) { null } + } + } + + /** No server could be reached; [reason] is the last launch's stable outcome, such as `auth_failed`. */ + class Unavailable(val reason: String) : Exception() + + /** Starts or attaches to the server. Runs on a worker thread; calls [done] exactly once. */ + fun interface Launch { + fun start(context: Context, done: (endpoint: Endpoint?, outcome: String) -> Unit) + } + + private val diagnostics = AndroidDiagnostics() + private val worker = Executors.newScheduledThreadPool(2) { task -> Thread(task, "openaide-$name").apply { isDaemon = true } } + private val requests = AtomicInteger() + private val lock = Any() + @Volatile private var endpoint: Endpoint? = null + private var starting: CompletableFuture? = null + private var heartbeat: ScheduledFuture<*>? = null + private var attempts = 0 + /** Bumped by [reset], so a launch begun for an earlier choice cannot become the endpoint. */ + private var generation = 0 + + /** The live server's endpoint, launching first when there is none. Blocks; never call on the main thread. */ + fun endpoint(context: Context): Endpoint { + endpoint?.let { return it } + val pending = synchronized(lock) { + endpoint?.let { return it } + starting ?: CompletableFuture().also { starting = it; start(context.applicationContext, it) } + } + return try { pending.get(startWaitMs, TimeUnit.MILLISECONDS) } + catch (error: java.util.concurrent.ExecutionException) { throw error.cause as? Unavailable ?: Unavailable("launch_failed") } + catch (error: java.util.concurrent.TimeoutException) { throw Unavailable("timeout") } + catch (error: InterruptedException) { throw Unavailable("interrupted") } + } + + /** Forgets an endpoint that stopped answering; the next request launches again. */ + fun invalidate(failed: Endpoint) = synchronized(lock) { + if (endpoint !== failed) return@synchronized + forget() + diagnostics.record(name, "invalidated", attempts, 0) + } + + /** Forgets the server and any launch in flight, such as after the user chose another one. */ + fun reset() = synchronized(lock) { + generation++ + forget() + starting?.completeExceptionally(Unavailable("reset")) + starting = null + } + + private fun forget() { + endpoint = null + heartbeat?.cancel(false) + heartbeat = null + } + + /** Counts open tasks by what they need from the phone: staying awake, or the user. */ + @Throws(IOException::class) + fun status(context: Context): ServerStatus { + val server = try { endpoint(context) } catch (error: Unavailable) { throw IOException("App Server unavailable") } + return status(server) + } + + /** [status] of the server already reached, or null when there is none; never starts one. */ + @Throws(IOException::class) + fun statusIfRunning(): ServerStatus? = endpoint?.let(::status) + + private fun status(server: Endpoint): ServerStatus { + var active = 0 + var waiting = 0 + var cursor: Any? = null + val seen = HashSet() + val attention = ArrayList() + repeat(MAX_TASK_PAGES) { + val params = JSONObject().put("lifecycle", "open") + if (cursor != null) params.put("cursor", cursor) + val tasks = request(server, "task/list", params, REQUEST_TIMEOUT_MS) + val page = tasks.optJSONArray("tasks") ?: throw IOException("Invalid task status") + for (index in 0 until page.length()) { + val task = page.getJSONObject(index) + when (ServerStatus.kind(task.optString("status")) ?: throw IOException("Unknown task status")) { + 1 -> active++ + 0 -> waiting++ + } + AttentionWire.attention(task)?.let(attention::add) + } + cursor = tasks.opt("nextCursor")?.takeIf { it != JSONObject.NULL } ?: return ServerStatus(active, waiting, attention) + if (!seen.add(cursor.toString())) throw IOException("Invalid task cursor") + } + throw IOException("Too many task pages") + } + + private fun start(context: Context, result: CompletableFuture) { + val attempt = ++attempts + val launched = generation + val started = SystemClock.elapsedRealtime() + diagnostics.record("${name}_start", "started", attempt, 0) + fun finish(outcome: String, server: Endpoint?) { + val current = synchronized(lock) { + val current = launched == generation + if (current) starting = null + if (current && server != null) { + endpoint = server + heartbeat?.cancel(false) + heartbeat = beat(server) + } + current + } + val recorded = if (current) outcome else "superseded" + diagnostics.record("${name}_start", recorded, attempt, SystemClock.elapsedRealtime() - started) + if (server != null && current) result.complete(server) else result.completeExceptionally(Unavailable(outcome)) + } + worker.execute { + try { + launch.start(context) { server, outcome -> + if (server == null) { finish(outcome, null); return@start } + worker.execute { + try { + initialize(server) + finish("ready", server) + } catch (error: IOException) { + finish("initialize_failed", null) + } + } + } + } catch (error: RuntimeException) { + finish("launch_failed", null) + } + } + } + + private fun initialize(server: Endpoint) { + request(server, "client/initialize", AttentionWire.initializeParams(CLIENT_ID), INITIALIZE_TIMEOUT_MS) + } + + /** Keeps this shell a live client, so the server stays while the app's process does. */ + private fun beat(server: Endpoint): ScheduledFuture<*> { + var failures = 0 + return worker.scheduleWithFixedDelay({ + try { + request(server, "client/heartbeat", JSONObject(), REQUEST_TIMEOUT_MS) + if (failures > 0) diagnostics.record("${name}_heartbeat", "recovered", failures, 0) + failures = 0 + } catch (error: IOException) { + failures++ + // Only the transition is logged; the schedule itself is the retry. + if (failures == 1) diagnostics.record("${name}_heartbeat", "failed", failures, 0) + if (failures >= MAX_HEARTBEAT_FAILURES) invalidate(server) + } + }, HEARTBEAT_MS, HEARTBEAT_MS, TimeUnit.MILLISECONDS) + } + + private fun request(server: Endpoint, method: String, params: JSONObject, timeoutMs: Int): JSONObject { + val id = "android-shell-${requests.incrementAndGet()}" + val connection = URL("http", server.host, server.port, server.path.ifEmpty { "/" }).openConnection() as HttpURLConnection + try { + connection.connectTimeout = 2_000 + connection.readTimeout = timeoutMs + connection.requestMethod = "POST" + connection.doOutput = true + connection.setRequestProperty("Authorization", "Bearer ${server.token}") + connection.setRequestProperty("Content-Type", "application/json") + connection.setRequestProperty("X-OpenAIDE-Connection-Id", CLIENT_ID) + connection.outputStream.use { + it.write(JSONObject().put("jsonrpc", "2.0").put("id", id).put("method", method).put("params", params).toString().toByteArray()) + } + if (connection.responseCode != 200) throw IOException("App Server refused the shell request") + val body = connection.inputStream.use { input -> + val buffer = java.io.ByteArrayOutputStream() + val chunk = ByteArray(8192) + while (true) { + val count = input.read(chunk) + if (count < 0) break + buffer.write(chunk, 0, count) + if (buffer.size() > MAX_RESPONSE_BYTES) throw IOException("App Server response too large") + } + buffer.toString("UTF-8").trim() + } + val message = if (body.startsWith("[")) { + val messages = JSONArray(body) + (0 until messages.length()).map(messages::getJSONObject).firstOrNull { it.optString("id") == id } + } else JSONObject(body) + if (message == null || message.has("error")) throw IOException("App Server shell request failed") + // A protocol result arrives wrapped in the envelope's own result. + val result = message.optJSONObject("result") ?: return JSONObject() + return result.optJSONObject("result") ?: result + } catch (error: org.json.JSONException) { + throw IOException("Invalid App Server response") + } finally { + connection.disconnect() + } + } + + private companion object { + const val CLIENT_ID = "android-app-shell" + const val HEARTBEAT_MS = 5_000L + const val MAX_HEARTBEAT_FAILURES = 3 + const val REQUEST_TIMEOUT_MS = 10_000 + const val INITIALIZE_TIMEOUT_MS = 60_000 + const val MAX_RESPONSE_BYTES = 1024 * 1024 + const val MAX_TASK_PAGES = 50 + } +} diff --git a/apps/android/app/src/main/kotlin/io/openaide/android/LocalServer.kt b/apps/android/app/src/main/kotlin/io/openaide/android/LocalServer.kt index 78f60cf8..bd660fb6 100644 --- a/apps/android/app/src/main/kotlin/io/openaide/android/LocalServer.kt +++ b/apps/android/app/src/main/kotlin/io/openaide/android/LocalServer.kt @@ -3,100 +3,44 @@ package io.openaide.android import android.content.Context import android.os.Handler import android.os.Looper -import android.os.SystemClock import java.io.IOException -import java.net.HttpURLConnection -import java.net.URL -import java.util.concurrent.CompletableFuture import java.util.concurrent.Executors -import java.util.concurrent.ScheduledFuture -import java.util.concurrent.TimeUnit -import java.util.concurrent.atomic.AtomicInteger -import org.json.JSONArray import org.json.JSONObject -/** Open tasks of the local App Server, as the background service needs them. */ -internal class ServerStatus( - @JvmField val active: Int, - @JvmField val waiting: Int, - /** The current Task Attention Event of each open task that has one. */ - @JvmField val attention: List, -) { - companion object { - /** 1 for a task that is working, 0 for one waiting on the user, -1 for one at rest, null when unknown. */ - @JvmStatic fun kind(status: String): Int? = when (status) { - "preparing", "starting", "running", "background", "stopping" -> 1 - "waiting" -> 0 - "idle", "interrupted", "failed", "completed" -> -1 - else -> null - } - } -} - /** - * The App Server in Termux, used as an App Shell on a computer uses its own. + * The App Server in Termux, held by this shell as [HeldServer] describes. * * Termux attaches to the server already serving the state or launches it, and - * hands back its loopback address and token. The shell then stays a client of - * that server for as long as the app's process lives, so the server ends by its - * own last-client rule when the app is gone. The token never reaches the WebView: - * [WorkspaceGateway] adds it to each request it carries. + * hands back its loopback address and token. */ internal object LocalServer { - class Endpoint(val host: String, val port: Int, val path: String, val token: String) - - class Unavailable : Exception() - - fun interface Connected { fun done(problem: String?) } - - private const val CLIENT_ID = "android-app-shell" private const val START_WAIT_MS = 80_000L - private const val HEARTBEAT_MS = 5_000L - private const val MAX_HEARTBEAT_FAILURES = 3 - private const val REQUEST_TIMEOUT_MS = 10_000 - private const val INITIALIZE_TIMEOUT_MS = 60_000 - private const val MAX_RESPONSE_BYTES = 1024 * 1024 - private const val MAX_TASK_PAGES = 50 - private val diagnostics = AndroidDiagnostics() private val main = Handler(Looper.getMainLooper()) - private val worker = Executors.newScheduledThreadPool(2) { task -> Thread(task, "openaide-local-server").apply { isDaemon = true } } - private val requests = AtomicInteger() - private val lock = Any() - @Volatile private var endpoint: Endpoint? = null - private var starting: CompletableFuture? = null - private var heartbeat: ScheduledFuture<*>? = null - private var attempts = 0 + private val worker = Executors.newSingleThreadExecutor { task -> Thread(task, "openaide-local-connect").apply { isDaemon = true } } - /** The live server's endpoint, starting the server first when there is none. Blocks; never call on the main thread. */ - fun endpoint(context: Context): Endpoint { - endpoint?.let { return it } - val pending = synchronized(lock) { - endpoint?.let { return it } - starting ?: CompletableFuture().also { starting = it; start(context.applicationContext, it) } - } - return try { pending.get(START_WAIT_MS, TimeUnit.MILLISECONDS) } catch (error: Exception) { throw Unavailable() } - } + fun interface Connected { fun done(problem: String?) } - /** Forgets an endpoint that stopped answering; the next request starts the server again. */ - fun invalidate(failed: Endpoint) = synchronized(lock) { - if (endpoint !== failed) return@synchronized - endpoint = null - heartbeat?.cancel(false) - heartbeat = null - diagnostics.record("local_server", "invalidated", attempts, 0) + val held = HeldServer("local_server", START_WAIT_MS) { context, done -> + main.post { + TermuxCommand.run(context, "start-termux.sh", TermuxCommand.variable("OPENAIDE_VERSION", BundledRuntime.expectedVersion())) { success, output -> + if (!success) { done(null, "termux_failed"); return@run } + val server = parse(output) + done(server, if (server == null) "invalid_handoff" else "ready") + } + } } /** Opens the gateway and reaches the local App Server before the workspace is shown. */ @JvmStatic fun connect(context: Context, callback: Connected) { val application = context.applicationContext worker.execute { - val problem = if (!WorkspaceGateway.start(application, true)) { + val problem = if (!WorkspaceGateway.start(application, ConnectionProfile.Kind.LOCAL)) { "OpenAIDE could not start its connection on this phone. Restart the app and try again." } else try { - endpoint(application) + held.endpoint(application) null - } catch (error: Unavailable) { + } catch (error: HeldServer.Unavailable) { "Your local workspace could not start. Try again, or check this phone in Connection settings." } main.post { callback.done(problem) } @@ -105,142 +49,20 @@ internal object LocalServer { /** Counts open tasks by what they need from the phone: staying awake, or the user. */ @JvmStatic @Throws(IOException::class) - fun status(context: Context): ServerStatus { - val server = try { endpoint(context) } catch (error: Unavailable) { throw IOException("Local App Server unavailable") } - return status(server) - } + fun status(context: Context): ServerStatus = held.status(context) /** - * [status] of a server this process already reaches, or null when there is none. - * Never starts one: watching this phone's Tasks while a paired computer is in - * use must not launch Termux (ADR-0070). + * [status] of the App Server this process already reaches, or null when there + * is none. Never starts one: watching this phone's Tasks while a paired + * computer is in use must not launch Termux (ADR-0070). */ @JvmStatic @Throws(IOException::class) - fun statusIfRunning(): ServerStatus? = endpoint?.let(::status) - - private fun status(server: Endpoint): ServerStatus { - var active = 0 - var waiting = 0 - var cursor: Any? = null - val seen = HashSet() - val attention = ArrayList() - repeat(MAX_TASK_PAGES) { - val params = JSONObject().put("lifecycle", "open") - if (cursor != null) params.put("cursor", cursor) - val tasks = request(server, "task/list", params, REQUEST_TIMEOUT_MS) - val page = tasks.optJSONArray("tasks") ?: throw IOException("Invalid task status") - for (index in 0 until page.length()) { - val task = page.getJSONObject(index) - when (ServerStatus.kind(task.optString("status")) ?: throw IOException("Unknown task status")) { - 1 -> active++ - 0 -> waiting++ - } - AttentionWire.attention(task)?.let(attention::add) - } - cursor = tasks.opt("nextCursor")?.takeIf { it != JSONObject.NULL } ?: return ServerStatus(active, waiting, attention) - if (!seen.add(cursor.toString())) throw IOException("Invalid task cursor") - } - throw IOException("Too many task pages") - } - - private fun start(context: Context, result: CompletableFuture) { - val attempt = ++attempts - val started = SystemClock.elapsedRealtime() - diagnostics.record("local_server_start", "started", attempt, 0) - fun finish(outcome: String, server: Endpoint?) { - synchronized(lock) { - starting = null - if (server != null) { - endpoint = server - heartbeat?.cancel(false) - heartbeat = beat(server) - } - } - diagnostics.record("local_server_start", outcome, attempt, SystemClock.elapsedRealtime() - started) - if (server != null) result.complete(server) else result.completeExceptionally(Unavailable()) - } - main.post { - TermuxCommand.run(context, "start-termux.sh", TermuxCommand.variable("OPENAIDE_VERSION", BundledRuntime.expectedVersion())) { success, output -> - if (!success) { finish("termux_failed", null); return@run } - worker.execute { - val server = parse(output) - if (server == null) { finish("invalid_handoff", null); return@execute } - try { - request(server, "client/initialize", AttentionWire.initializeParams(CLIENT_ID), INITIALIZE_TIMEOUT_MS) - finish("ready", server) - } catch (error: IOException) { - finish("initialize_failed", null) - } - } - } - } - } + fun statusIfRunning(): ServerStatus? = held.statusIfRunning() /** The handoff line the App Server prints: where it listens and the token it accepts. */ - internal fun parse(output: String): Endpoint? = try { + internal fun parse(output: String): HeldServer.Endpoint? = try { val line = JSONObject(output.trim().lineSequence().firstOrNull() ?: "") - val url = URL(line.getString("endpointUrl")) - val token = line.getString("authToken") - if (line.optString("kind") != "localHttp" || url.protocol != "http" || url.host != "127.0.0.1" || url.port <= 0 - || token.isEmpty() || token.any { it.isWhitespace() }) null - else Endpoint(url.host, url.port, url.path.trimEnd('/'), token) + if (line.optString("kind") != "localHttp") null + else HeldServer.Endpoint.of(line.getString("endpointUrl"), line.getString("authToken")) } catch (error: Exception) { null } - - /** Keeps this shell a live client, so the server stays while the app's process does. */ - private fun beat(server: Endpoint): ScheduledFuture<*> { - var failures = 0 - return worker.scheduleWithFixedDelay({ - try { - request(server, "client/heartbeat", JSONObject(), REQUEST_TIMEOUT_MS) - if (failures > 0) diagnostics.record("local_server_heartbeat", "recovered", failures, 0) - failures = 0 - } catch (error: IOException) { - failures++ - // Only the transition is logged; the schedule itself is the retry. - if (failures == 1) diagnostics.record("local_server_heartbeat", "failed", failures, 0) - if (failures >= MAX_HEARTBEAT_FAILURES) invalidate(server) - } - }, HEARTBEAT_MS, HEARTBEAT_MS, TimeUnit.MILLISECONDS) - } - - private fun request(server: Endpoint, method: String, params: JSONObject, timeoutMs: Int): JSONObject { - val id = "android-shell-${requests.incrementAndGet()}" - val connection = URL("http", server.host, server.port, server.path.ifEmpty { "/" }).openConnection() as HttpURLConnection - try { - connection.connectTimeout = 2_000 - connection.readTimeout = timeoutMs - connection.requestMethod = "POST" - connection.doOutput = true - connection.setRequestProperty("Authorization", "Bearer ${server.token}") - connection.setRequestProperty("Content-Type", "application/json") - connection.setRequestProperty("X-OpenAIDE-Connection-Id", CLIENT_ID) - connection.outputStream.use { - it.write(JSONObject().put("jsonrpc", "2.0").put("id", id).put("method", method).put("params", params).toString().toByteArray()) - } - if (connection.responseCode != 200) throw IOException("App Server refused the shell request") - val body = connection.inputStream.use { input -> - val buffer = java.io.ByteArrayOutputStream() - val chunk = ByteArray(8192) - while (true) { - val count = input.read(chunk) - if (count < 0) break - buffer.write(chunk, 0, count) - if (buffer.size() > MAX_RESPONSE_BYTES) throw IOException("App Server response too large") - } - buffer.toString("UTF-8").trim() - } - val message = if (body.startsWith("[")) { - val messages = JSONArray(body) - (0 until messages.length()).map(messages::getJSONObject).firstOrNull { it.optString("id") == id } - } else JSONObject(body) - if (message == null || message.has("error")) throw IOException("App Server shell request failed") - // A protocol result arrives wrapped in the envelope's own result. - val result = message.optJSONObject("result") ?: return JSONObject() - return result.optJSONObject("result") ?: result - } catch (error: org.json.JSONException) { - throw IOException("Invalid App Server response") - } finally { - connection.disconnect() - } - } } diff --git a/apps/android/app/src/main/kotlin/io/openaide/android/RemotePairing.kt b/apps/android/app/src/main/kotlin/io/openaide/android/RemotePairing.kt index c5a9157c..c458e48f 100644 --- a/apps/android/app/src/main/kotlin/io/openaide/android/RemotePairing.kt +++ b/apps/android/app/src/main/kotlin/io/openaide/android/RemotePairing.kt @@ -75,7 +75,7 @@ internal object RemotePairing { @JvmStatic fun connect(context: Context, callback: Connected) { val application = context.applicationContext RemoteNode.scope.launch { - val problem = if (!WorkspaceGateway.start(application, false)) { + val problem = if (!WorkspaceGateway.start(application, ConnectionProfile.Kind.PAIRED)) { "OpenAIDE could not start its connection on this phone. Restart the app and try again." } else try { RemoteNode.connection(application) @@ -98,12 +98,12 @@ internal object RemotePairing { { event, outcome, durationMs, fields -> diagnostics.record(event, outcome, 0, durationMs, fields) }, object : ComputerSwitch.Steps { override suspend fun connect(target: String?): Connection? { - if (!WorkspaceGateway.start(application, target == null)) { + if (!WorkspaceGateway.start(application, if (target == null) ConnectionProfile.Kind.LOCAL else ConnectionProfile.Kind.PAIRED)) { throw ComputerSwitch.Failed("gateway", "OpenAIDE could not start its connection on this phone. Restart the app and try again.") } if (target == null) { // Starting the App Server in Termux cannot be interrupted; a Cancel ignores its result. - try { LocalServer.endpoint(application) } catch (error: LocalServer.Unavailable) { + try { LocalServer.held.endpoint(application) } catch (error: HeldServer.Unavailable) { throw ComputerSwitch.Failed("local_unavailable", "Your local workspace could not start. Try again, or check this phone in Connection settings.") } return null @@ -117,6 +117,8 @@ internal object RemotePairing { override fun apply(target: String?, connected: Connection?) { val store = ConnectionStore(application) + // Leaving an SSH Computer ends its forward; its App Server keeps running there. + SshServer.reset() if (target == null) { store.selectLocal() // The computer left behind keeps its work; only this phone's link to it ends. diff --git a/apps/android/app/src/main/kotlin/io/openaide/android/SshServer.kt b/apps/android/app/src/main/kotlin/io/openaide/android/SshServer.kt new file mode 100644 index 00000000..ade32048 --- /dev/null +++ b/apps/android/app/src/main/kotlin/io/openaide/android/SshServer.kt @@ -0,0 +1,234 @@ +package io.openaide.android + +import android.content.Context +import android.os.Handler +import android.os.Looper +import android.os.SystemClock +import io.openaide.android.ssh.SshComputer +import io.openaide.android.ssh.SshEvents +import io.openaide.android.ssh.SshException +import io.openaide.android.ssh.SshLaunch +import io.openaide.android.ssh.Target +import java.io.File +import java.io.IOException +import java.net.HttpURLConnection +import java.net.URL +import java.security.SecureRandom +import java.util.concurrent.Executors +import org.json.JSONObject + +/** + * The App Server on the selected SSH Computer (ADR-0069), held by this shell as + * [HeldServer] describes. + * + * The SSH library starts or attaches to the App Server there and forwards it to + * this phone's loopback; this object chooses the computer, supplies the release + * archive, and remembers what the last launch learned for Settings. A launch that + * finds an App Server too old to use fails with `update_required` until the user + * confirms [update]. + */ +internal object SshServer { + /** The first start may download the release and upload it over a phone's network. */ + private const val START_WAIT_MS = 300_000L + private const val MAX_ARCHIVE_BYTES = 256L * 1024 * 1024 + private const val RELEASE_URL = "https://github.com/OldKrab/OpenAIDE/releases/download/v%s/openaide-app-server-linux-x64-%s.tar.gz" + private const val BUNDLED_ARCHIVE = "ssh/openaide-app-server-linux-x64.bin" + + fun interface Connected { fun done(problem: String?, reason: String?) } + + /** What the last launch learned about the App Server there, for Settings. */ + class Status(@JvmField val runningVersion: String, @JvmField val updateAvailable: Boolean, @JvmField val suggestLinger: Boolean) + + private val diagnostics = AndroidDiagnostics() + private val main = Handler(Looper.getMainLooper()) + private val worker = Executors.newSingleThreadExecutor { task -> Thread(task, "openaide-ssh-control").apply { isDaemon = true } } + private val lock = Any() + private var native: Pair? = null + @Volatile private var replaceNext = false + @Volatile private var status: Status? = null + /** The version of the App Server there when it is too old for this app; null otherwise. */ + @Volatile private var requiredUpdate: String? = null + /** Shows the launch stage while the connection screen waits; set only by that screen. */ + @Volatile private var stageListener: ((String) -> Unit)? = null + + val held = HeldServer("ssh_server", START_WAIT_MS) { context, done -> launch(context, done) } + + @JvmStatic fun status(): Status? = status + @JvmStatic fun requiredUpdate(): String? = requiredUpdate + + /** Opens the gateway and reaches the App Server on the selected SSH Computer before the workspace is shown. */ + @JvmStatic fun connect(context: Context, stage: ((String) -> Unit)?, callback: Connected) { + val application = context.applicationContext + stageListener = stage + worker.execute { + var reason: String? = null + val problem = if (!WorkspaceGateway.start(application, ConnectionProfile.Kind.SSH)) { + "OpenAIDE could not start its connection on this phone. Restart the app and try again." + } else try { + held.endpoint(application) + null + } catch (error: HeldServer.Unavailable) { + reason = error.reason + message(error.reason) + } + stageListener = null + main.post { callback.done(problem, reason) } + } + } + + /** Task status for the background service, through the forward. */ + @JvmStatic @Throws(IOException::class) + fun status(context: Context): ServerStatus = held.status(context) + + /** Makes the next launch replace the App Server there; the user confirmed the warning. */ + @JvmStatic fun update() { + replaceNext = true + held.reset() + } + + /** Forgets the computer in use, such as when the user chose another or forgot it. */ + @JvmStatic fun reset() { + held.reset() + val closing = synchronized(lock) { native.also { native = null } } + status = null + requiredUpdate = null + replaceNext = false + if (closing != null) worker.execute { release(closing.second) } + } + + /** Ends the keeper on [computer]; reports `stopped`, `notRunning`, `stillRunning`, or a failure class. */ + @JvmStatic fun stop(context: Context, computer: SshComputers.Computer, store: ConnectionStore, done: (String) -> Unit) { + val application = context.applicationContext + worker.execute { + val started = SystemClock.elapsedRealtime() + val outcome = try { + val selected = synchronized(lock) { native?.takeIf { it.first == computer.id }?.second } + val stopping = selected ?: open(application, computer, store) + try { stopping.stop(operation()) } finally { if (selected == null) release(stopping) } + } catch (error: SshException.Failed) { + error.`class` + } catch (error: RuntimeException) { + "stop_failed" + } + diagnostics.record("ssh_server_stop", outcome, 1, SystemClock.elapsedRealtime() - started) + main.post { done(outcome) } + } + } + + private fun launch(context: Context, done: (HeldServer.Endpoint?, String) -> Unit) { + val store = ConnectionStore(context) + val computer = store.sshComputer() ?: return done(null, "not_selected") + val replace = replaceNext + replaceNext = false + try { + val selected = synchronized(lock) { + native?.takeIf { it.first == computer.id }?.second ?: open(context, computer, store).also { opened -> + native?.second?.let(::release) + native = computer.id to opened + } + } + when (val result = selected.launch(replace, operation())) { + is SshLaunch.Ready -> { + val ready = result.ready + requiredUpdate = null + status = Status(ready.appVersion, ready.updateAvailable, ready.suggestLinger) + val endpoint = HeldServer.Endpoint.of(ready.endpointUrl, ready.authToken) + done(endpoint, if (endpoint == null) "invalid_handoff" else "ready") + } + is SshLaunch.UpdateRequired -> { + requiredUpdate = result.runningVersion + done(null, "update_required") + } + } + } catch (error: SshException.Failed) { + done(null, error.`class`) + } + } + + private fun open(context: Context, computer: SshComputers.Computer, store: ConnectionStore): SshComputer = SshComputer( + target(computer), computer.hostKey, store.sshKey(), BuildConfig.SSH_APP_SERVER_VERSION, BuildConfig.SSH_APP_SERVER_SHA256, + File(context.cacheDir, "ssh-app-server").absolutePath, Events(context.applicationContext)) + + /** Ends the session and frees the library's object; a launch still using it fails. */ + private fun release(computer: SshComputer) { + computer.disconnect() + computer.close() + } + + @JvmStatic fun target(computer: SshComputers.Computer) = Target(computer.host, computer.port.toUShort(), computer.user) + + /** A correlation id for one launch or stop; it names nothing about the computer. */ + private fun operation(): String = "android-ssh-" + ByteArray(6).also(SecureRandom()::nextBytes).joinToString("") { "%02x".format(it.toInt() and 0xff) } + + /** A sentence for a launch failure class; the class itself stays in diagnostics. */ + @JvmStatic fun message(reason: String): String = when (reason) { + "host_key_changed" -> "This computer’s identity changed, so OpenAIDE did not connect. If you expected the change, forget the computer in Settings and add it again." + "auth_failed", "auth_prompt_unsupported" -> "The computer did not accept this phone’s key. Add the phone’s public key to ~/.ssh/authorized_keys there, then try again." + "host_unknown" -> "OpenAIDE could not find that computer. Check its address and your network." + "unreachable", "connection_refused", "connection_lost", "timeout" -> "OpenAIDE could not reach the computer. Check that it is on and reachable from this phone." + "unavailable_in_build" -> "This build of OpenAIDE cannot install itself on SSH computers." + "download_failed", "checksum_mismatch", "archive_invalid" -> "OpenAIDE could not download itself for the computer. Check your internet connection and try again." + "unsupported_platform" -> "OpenAIDE runs only on 64-bit x86 Linux computers." + "update_required" -> "The computer runs an older OpenAIDE that this app cannot use." + "server_newer" -> "The computer runs a newer OpenAIDE. Update this app to use it." + else -> "OpenAIDE could not start on the computer. Try again." + } + + /** The library's events for an operation outside a launch, such as adding the phone's key. */ + fun events(context: Context): SshEvents = Events(context.applicationContext) + + /** The library's lifecycle events, its stages, and the release it needs. */ + private class Events(private val context: Context) : SshEvents { + override fun log(event: String, fields: String) { + diagnostics.recordFields(event, fields) + } + + override fun stage(stage: String) { + stageListener?.let { listener -> main.post { listener(stage) } } + } + + override fun releaseArchive(): String { + val started = SystemClock.elapsedRealtime() + val source = if (BuildConfig.SSH_APP_SERVER_BUNDLED) "bundled" else "download" + diagnostics.recordFields("ssh_release_fetch_started", JSONObject().put("source", source).toString()) + val target = File(context.cacheDir, "ssh-release-${System.nanoTime()}.tar.gz") + val outcome = try { + if (BuildConfig.SSH_APP_SERVER_BUNDLED) context.assets.open(BUNDLED_ARCHIVE).use { input -> target.outputStream().use { input.copyTo(it) } } + else download(target) + "success" + } catch (error: IOException) { + target.delete() + "failure" + } + diagnostics.recordFields("ssh_release_fetch_completed", JSONObject().put("source", source).put("outcome", outcome) + .put("durationMs", SystemClock.elapsedRealtime() - started).toString()) + if (outcome != "success") throw SshException.Failed("fetch", "download_failed") + return target.absolutePath + } + + private fun download(target: File) { + val version = BuildConfig.SSH_APP_SERVER_VERSION + val connection = URL(RELEASE_URL.format(version, version)).openConnection() as HttpURLConnection + try { + connection.connectTimeout = 15_000 + connection.readTimeout = 30_000 + if (connection.responseCode != 200) throw IOException("Release unavailable") + connection.inputStream.use { input -> + target.outputStream().use { output -> + val buffer = ByteArray(64 * 1024) + var total = 0L + while (true) { + val count = input.read(buffer) + if (count < 0) break + total += count + if (total > MAX_ARCHIVE_BYTES) throw IOException("Release too large") + output.write(buffer, 0, count) + } + } + } + } finally { + connection.disconnect() + } + } + } +} diff --git a/apps/android/app/src/main/kotlin/io/openaide/android/SshSetup.kt b/apps/android/app/src/main/kotlin/io/openaide/android/SshSetup.kt new file mode 100644 index 00000000..51c85d86 --- /dev/null +++ b/apps/android/app/src/main/kotlin/io/openaide/android/SshSetup.kt @@ -0,0 +1,213 @@ +package io.openaide.android + +import android.app.Activity +import android.app.AlertDialog +import android.content.ClipData +import android.content.ClipboardManager +import android.graphics.Typeface +import android.os.SystemClock +import android.text.InputType +import android.view.View +import android.widget.EditText +import android.widget.LinearLayout +import android.widget.TextView +import io.openaide.android.ssh.SshException +import io.openaide.android.ssh.authorizeKey +import io.openaide.android.ssh.hostKeyFingerprint +import io.openaide.android.ssh.publicKeyLine +import io.openaide.android.ssh.readHostKey +import java.security.SecureRandom +import java.util.concurrent.Executors + +/** + * Adds an SSH Computer with native dialogs, so it works with or without a workspace + * open (ADR-0069): the address, then the computer's host key for the user to accept, + * then this phone's public key to add there, either by the user or with a password + * used once. The accepted host key is pinned; the password is never kept. + */ +internal class SshSetup private constructor(private val activity: Activity, private val added: Runnable) { + private val diagnostics = AndroidDiagnostics() + private val started = SystemClock.elapsedRealtime() + private var destination = "" + private var name = "" + + companion object { + private val worker = Executors.newSingleThreadExecutor { task -> Thread(task, "openaide-ssh-setup").apply { isDaemon = true } } + private const val KEY_COMMENT = "openaide-android" + + /** Starts the flow; [added] runs once the computer is saved and selected. */ + @JvmStatic fun start(activity: Activity, added: Runnable) { + SshSetup(activity, added).also { + it.diagnostics.record("ssh_add", "started", 0, 0) + it.askAddress(null) + } + } + } + + private fun askAddress(error: String?) { + val form = form() + form.addView(text("OpenAIDE installs itself on the Linux computer over SSH and keeps running there while agents work.")) + val host = field(form, "Host", "user@host or user@host:port", destination) + val label = field(form, "Name (optional)", "Build server", name) + if (error != null) host.error = error + dialog("Add an SSH computer", form, "Next", { dialog -> + destination = host.text.toString().trim() + name = label.text.toString() + val parsed = SshComputers.parseDestination(destination, null) + if (parsed == null) { host.error = "Enter the user and the computer, such as dev@192.168.1.20"; return@dialog } + dialog.dismiss() + readKey(parsed) + }) + } + + private fun readKey(parsed: Array) { + val progress = progress("Connecting to the computer…") + worker.execute { + val target = SshServer.target(computer("", parsed, "")) + val result = try { + val key = readHostKey(target) + Result.success(key to hostKeyFingerprint(key)) + } catch (error: SshException.Failed) { Result.failure(error) } + ui { + progress.dismiss() + result.fold({ (key, fingerprint) -> confirmHostKey(parsed, key, fingerprint) }, { failure -> + finish("host_key_${(failure as SshException.Failed).`class`}") + askAddress(SshServer.message(failure.`class`)) + }) + } + } + } + + private fun confirmHostKey(parsed: Array, key: String, fingerprint: String) { + val form = form() + form.addView(text("OpenAIDE has not connected to ${parsed[0]} before. Check that the computer shows this fingerprint for its host key:")) + form.addView(mono(fingerprint)) + form.addView(text("On the computer, ssh-keygen -lf /etc/ssh/ssh_host_*_key.pub prints it. If the key ever changes, OpenAIDE will not connect.")) + dialog("Is this the right computer?", form, "Trust", { dialog -> + dialog.dismiss() + diagnostics.record("ssh_add_host_key", "accepted", 0, SystemClock.elapsedRealtime() - started) + authorize(parsed, key) + }, onCancel = { finish("host_key_declined") }) + } + + private fun authorize(parsed: Array, hostKey: String) { + val store = ConnectionStore(activity) + val line = try { publicKeyLine(store.sshKey(), KEY_COMMENT) } catch (error: Exception) { null } + if (line == null) { finish("key_unavailable"); notice("This phone could not create its SSH key. Restart OpenAIDE and try again."); return } + val form = form() + form.addView(text("Let this phone sign in to ${parsed[2]} by adding its key to ~/.ssh/authorized_keys there:")) + val key = mono(line) + form.addView(key) + form.addView(text("Or enter the password once and OpenAIDE adds the key. The password is not kept.")) + val password = field(form, "Password (optional)", "", "") + password.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD + // A password input switches to monospace; keep the hint in the dialog's typeface. + password.typeface = Typeface.DEFAULT + dialog("Let this phone sign in", form, "Connect", { dialog -> + val secret = password.text.toString() + password.setText("") + if (secret.isEmpty()) { dialog.dismiss(); save(parsed, hostKey); return@dialog } + dialog.dismiss() + val progress = progress("Adding this phone’s key…") + worker.execute { + val failure = try { + authorizeKey(SshServer.target(computer("", parsed, hostKey)), hostKey, secret, line, SshServer.events(activity)) + null + } catch (error: SshException.Failed) { error.`class` } + ui { + progress.dismiss() + diagnostics.record("ssh_add_authorize", failure ?: "success", 1, SystemClock.elapsedRealtime() - started) + if (failure == null) save(parsed, hostKey) else { finish("authorize_$failure"); notice(authorizeMessage(failure)) } + } + } + }, neutral = "Copy key" to { + activity.getSystemService(ClipboardManager::class.java).setPrimaryClip(ClipData.newPlainText("OpenAIDE SSH key", line)) + diagnostics.record("ssh_add_key", "copied", 0, 0) + }, onCancel = { finish("key_declined") }) + } + + private fun save(parsed: Array, hostKey: String) { + val id = ByteArray(16).also(SecureRandom()::nextBytes).joinToString("") { "%02x".format(it.toInt() and 0xff) } + val store = ConnectionStore(activity) + store.saveSsh(computer(id, parsed, hostKey)) + // A connection to the computer used until now must not outlive the choice. + RemoteNode.disconnect() + SshServer.reset() + finish("added") + added.run() + } + + private fun computer(id: String, parsed: Array, hostKey: String) = SshComputers.Computer( + id, SshComputers.name(name).ifEmpty { parsed[0] }, parsed[0], parsed[1].toInt(), parsed[2], hostKey) + + private fun authorizeMessage(reason: String) = when (reason) { + "auth_failed" -> "The computer did not accept that password." + "authorized_keys_unwritable" -> "OpenAIDE signed in but could not add the key to ~/.ssh/authorized_keys. Add it there yourself, then connect." + else -> SshServer.message(reason) + } + + private fun finish(outcome: String) = diagnostics.record("ssh_add", outcome, 0, SystemClock.elapsedRealtime() - started) + + private fun ui(action: () -> Unit) = activity.runOnUiThread { if (!activity.isDestroyed) action() } + + private fun notice(message: String) { + if (activity.isDestroyed) return + AlertDialog.Builder(activity).setTitle("SSH computer not added").setMessage(message).setPositiveButton("OK", null).show() + } + + private fun progress(message: String): AlertDialog = + AlertDialog.Builder(activity).setMessage(message).setCancelable(false).show() + + /** A dialog whose positive button may keep it open to show an error in the form. */ + private fun dialog(title: String, content: View, positive: String, onPositive: (AlertDialog) -> Unit, + neutral: Pair Unit>? = null, onCancel: () -> Unit = { finish("cancelled") }) { + if (activity.isDestroyed) return + val builder = AlertDialog.Builder(activity).setTitle(title).setView(content) + .setPositiveButton(positive, null).setNegativeButton("Cancel") { _, _ -> onCancel() } + .setOnCancelListener { onCancel() } + if (neutral != null) builder.setNeutralButton(neutral.first, null) + val dialog = builder.create() + dialog.setOnShowListener { + dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener { onPositive(dialog) } + if (neutral != null) dialog.getButton(AlertDialog.BUTTON_NEUTRAL).setOnClickListener { neutral.second() } + } + dialog.show() + } + + private fun form() = LinearLayout(activity).apply { + orientation = LinearLayout.VERTICAL + val padding = (20 * resources.displayMetrics.density).toInt() + setPadding(padding, padding / 2, padding, 0) + } + + /** Adds a labelled single-line input to [form]; the label stays visible once the user types. */ + private fun field(form: LinearLayout, label: String, hint: String, value: String): EditText { + val caption = TextView(activity).apply { + text = label + setPadding(0, 16, 0, 0) + } + val input = EditText(activity).apply { + this.hint = hint + contentDescription = label + setText(value) + isSingleLine = true + importantForAutofill = View.IMPORTANT_FOR_AUTOFILL_NO + } + caption.labelFor = View.generateViewId().also { input.id = it } + form.addView(caption) + form.addView(input) + return input + } + + private fun text(value: String) = TextView(activity).apply { + text = value + setPadding(0, 8, 0, 8) + } + + private fun mono(value: String) = TextView(activity).apply { + text = value + typeface = Typeface.MONOSPACE + setTextIsSelectable(true) + setPadding(0, 8, 0, 8) + } +} diff --git a/apps/android/app/src/main/kotlin/io/openaide/android/WorkspaceGateway.kt b/apps/android/app/src/main/kotlin/io/openaide/android/WorkspaceGateway.kt index 78a432fc..d4a431e0 100644 --- a/apps/android/app/src/main/kotlin/io/openaide/android/WorkspaceGateway.kt +++ b/apps/android/app/src/main/kotlin/io/openaide/android/WorkspaceGateway.kt @@ -20,9 +20,10 @@ import kotlinx.coroutines.runBlocking * It answers Frontend requests from the APK and carries App Server requests to * the App Server, exactly as the Web Shell's proxy does on a computer: over one * iroh stream per request to the paired computer, or over loopback with the - * local token to the App Server in Termux. Recovery after a lost connection - * stays where it already is: the Frontend's resumable session retries, and each - * retry asks [RemoteNode] or [LocalServer] for a live connection. + * held token to the App Server in Termux or the SSH forward to an SSH Computer. + * Recovery after a lost connection stays where it already is: the Frontend's + * resumable session retries, and each retry asks [RemoteNode] or the + * [HeldServer] for a live connection. */ internal object WorkspaceGateway { private const val MAX_HEAD_BYTES = 16 * 1024 @@ -42,8 +43,8 @@ internal object WorkspaceGateway { fun onFailure(listener: ((RemoteNode.Failure) -> Unit)?) { failureListener = listener } /** Owns the workspace's loopback port for the rest of the process; false when another app holds it. */ - @Synchronized fun start(context: Context, local: Boolean): Boolean { - val port = if (local) ConnectionProfile.LOCAL_PORT else ConnectionProfile.PAIRED_PORT + @Synchronized fun start(context: Context, kind: ConnectionProfile.Kind): Boolean { + val port = kind.port if (listeners.containsKey(port)) return true val application = context.applicationContext val bound = try { @@ -60,14 +61,15 @@ internal object WorkspaceGateway { workers.execute { while (true) { val socket = try { bound.accept() } catch (error: java.io.IOException) { break } - workers.execute { socket.use { serve(application, it, local, port) } } + workers.execute { socket.use { serve(application, it, kind) } } } diagnostics.record("workspace_gateway", "stopped", port, 0) } return true } - private fun serve(context: Context, socket: Socket, local: Boolean, port: Int) { + private fun serve(context: Context, socket: Socket, kind: ConnectionProfile.Kind) { + val port = kind.port try { socket.tcpNoDelay = true socket.soTimeout = HEAD_TIMEOUT_MS @@ -83,8 +85,11 @@ internal object WorkspaceGateway { socket.soTimeout = 0 val target = GatewayHttp.appServerTarget(head) if (target == null) serveFrontend(context, socket, head) - else if (local) forwardLocal(context, socket, input, head, target, bodyStart) - else forward(context, socket, input, head, target, bodyStart) + else when (kind) { + ConnectionProfile.Kind.PAIRED -> forward(context, socket, input, head, target, bodyStart) + ConnectionProfile.Kind.LOCAL -> forwardHeld(context, LocalServer.held, socket, input, head, target, bodyStart) + ConnectionProfile.Kind.SSH -> forwardHeld(context, SshServer.held, socket, input, head, target, bodyStart) + } } catch (error: java.io.IOException) { // The WebView abandoned the request; there is no one left to answer. } @@ -153,11 +158,11 @@ internal object WorkspaceGateway { } } - /** Carries one request to the App Server in Termux, adding the token only the shell holds. */ - private fun forwardLocal(context: Context, socket: Socket, input: InputStream, head: GatewayHttp.Head, target: String, bodyStart: ByteArray) { + /** Carries one request to a held App Server, adding the token only the shell holds. */ + private fun forwardHeld(context: Context, held: HeldServer, socket: Socket, input: InputStream, head: GatewayHttp.Head, target: String, bodyStart: ByteArray) { val request = requests.incrementAndGet() val started = SystemClock.elapsedRealtime() - val (server, upstream) = try { openLocal(context) } catch (error: LocalServer.Unavailable) { + val (server, upstream) = try { openHeld(context, held) } catch (error: HeldServer.Unavailable) { diagnostics.record("workspace_gateway_request", "local_unavailable", request, SystemClock.elapsedRealtime() - started) reply(socket, 502, "Bad Gateway") return @@ -211,17 +216,17 @@ internal object WorkspaceGateway { } } - /** Connects to the local App Server, starting it again once when it has stopped since the last request. */ - private fun openLocal(context: Context): Pair { + /** Connects to a held App Server, launching it again once when it has stopped since the last request. */ + private fun openHeld(context: Context, held: HeldServer): Pair { repeat(2) { - val server = LocalServer.endpoint(context) + val server = held.endpoint(context) try { return server to Socket().apply { connect(java.net.InetSocketAddress(InetAddress.getByName(server.host), server.port), 2_000) } } catch (error: java.io.IOException) { - LocalServer.invalidate(server) + held.invalidate(server) } } - throw LocalServer.Unavailable() + throw HeldServer.Unavailable("unreachable") } /** Opens a stream, replacing a connection that died since the last request once. */ diff --git a/apps/android/app/src/test/java/io/openaide/android/ConnectionProfileTest.java b/apps/android/app/src/test/java/io/openaide/android/ConnectionProfileTest.java index db78ebbb..c84f53a2 100644 --- a/apps/android/app/src/test/java/io/openaide/android/ConnectionProfileTest.java +++ b/apps/android/app/src/test/java/io/openaide/android/ConnectionProfileTest.java @@ -30,4 +30,15 @@ public class ConnectionProfileTest { // The Web Shell that Termux used to serve is no longer an origin of the app. assertFalse(profile.owns("http://127.0.0.1:5474/")); } + + @Test public void anSshComputerHasItsOwnOriginAndIsWatchedInTheBackground() { + ConnectionProfile profile = ConnectionProfile.ssh(); + assertFalse(profile.local); + assertTrue(profile.held()); + assertTrue(profile.owns("http://127.0.0.1:5477/")); + assertFalse(profile.owns("http://127.0.0.1:5476/")); + assertFalse(profile.owns("http://127.0.0.1:5475/")); + assertTrue(ConnectionProfile.local().held()); + assertFalse(ConnectionProfile.paired().held()); + } } diff --git a/apps/android/app/src/test/java/io/openaide/android/SshComputersTest.java b/apps/android/app/src/test/java/io/openaide/android/SshComputersTest.java new file mode 100644 index 00000000..9052fe7c --- /dev/null +++ b/apps/android/app/src/test/java/io/openaide/android/SshComputersTest.java @@ -0,0 +1,60 @@ +package io.openaide.android; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +import java.util.List; +import org.junit.Test; + +public class SshComputersTest { + private static final String A = "0123456789abcdef0123456789abcdef"; + private static final String B = "fedcba9876543210fedcba9876543210"; + private static final String KEY = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl"; + + private static SshComputers.Computer computer(String id, String name) { + return new SshComputers.Computer(id, name, "devbox.example", 2222, "dev", KEY); + } + + @Test public void computersSurviveStorageInOrderAndNamesStayOnOneLine() { + List computers = SshComputers.with(SshComputers.with(SshComputers.parse(""), computer(A, "Build\tbox\nnext")), computer(B, "Lab")); + List stored = SshComputers.parse(SshComputers.format(computers)); + assertEquals(2, stored.size()); + assertEquals("Build box next", stored.get(0).name); + assertEquals(2222, stored.get(0).port); + assertEquals(KEY, stored.get(0).hostKey); + assertEquals(B, stored.get(1).id); + } + + @Test public void addingAgainReplacesInPlaceAndForgettingRemovesOnlyThatOne() { + List computers = SshComputers.with(SshComputers.with(SshComputers.parse(""), computer(A, "One")), computer(B, "Two")); + computers = SshComputers.with(computers, computer(A, "Renamed")); + assertEquals("Renamed", computers.get(0).name); + assertEquals(1, SshComputers.without(computers, A).size()); + assertNull(SshComputers.find(SshComputers.without(computers, A), A)); + } + + @Test public void storedEntriesThatCouldReachAShellAreDropped() { + String bad = A + "\tName\tdev box\t22\tdev\t" + KEY + "\n" + + B + "\tName\thost\t22\tdev;rm\t" + KEY + "\n" + + A + "\tName\thost\t0\tdev\t" + KEY + "\n" + + A + "\tName\thost\t22\tdev\tnot a key"; + assertEquals(0, SshComputers.parse(bad).size()); + } + + @Test public void destinationsReadAsTheUserTypesThem() { + assertArrayEquals(new String[]{"devbox", "22", "dev"}, SshComputers.parseDestination("dev@DevBox", null)); + assertArrayEquals(new String[]{"10.0.0.5", "2222", "dev"}, SshComputers.parseDestination(" dev@10.0.0.5:2222 ", null)); + assertArrayEquals(new String[]{"fe80::1", "2200", "dev"}, SshComputers.parseDestination("dev@[fe80::1]:2200", null)); + assertArrayEquals(new String[]{"fe80::1", "22", "dev"}, SshComputers.parseDestination("dev@fe80::1", null)); + assertNull(SshComputers.parseDestination("devbox", null)); + assertNull(SshComputers.parseDestination("dev@host:99999", null)); + assertNull(SshComputers.parseDestination("dev@-oProxyCommand=x", null)); + assertNull(SshComputers.parseDestination("dev@host name", null)); + } + + @Test public void theDestinationShownNamesAPortOnlyWhenItIsNotTheDefault() { + assertEquals("dev@devbox.example:2222", computer(A, "x").destination()); + assertEquals("dev@[fe80::1]", new SshComputers.Computer(A, "x", "fe80::1", 22, "dev", KEY).destination()); + } +} diff --git a/apps/android/app/src/test/kotlin/io/openaide/android/GatewayHttpTest.kt b/apps/android/app/src/test/kotlin/io/openaide/android/GatewayHttpTest.kt index d72809fe..063ffbdb 100644 --- a/apps/android/app/src/test/kotlin/io/openaide/android/GatewayHttpTest.kt +++ b/apps/android/app/src/test/kotlin/io/openaide/android/GatewayHttpTest.kt @@ -31,6 +31,8 @@ class GatewayHttpTest { assertEquals("/probe/upload?taskId=1", target("POST /__openaide-app-server/upload?taskId=1 HTTP/1.1")) assertEquals("/probe/upload/chunk", target("POST /__openaide-app-server/upload/chunk HTTP/1.1")) assertEquals("/probe/download?messageId=m", target("GET /__openaide-app-server/download?messageId=m HTTP/1.1")) + assertEquals("/probe/terminal", GatewayHttp.appServerTarget(head("GET /__openaide-app-server/terminal HTTP/1.1", *trusted, "Upgrade: websocket"))) + assertEquals("/probe", target("GET /__openaide-app-server/terminal HTTP/1.1")) assertNull(target("GET /assets/index.js HTTP/1.1")) assertNull(target("GET /__openaide-app-server-other HTTP/1.1")) } diff --git a/apps/android/scripts/setup-ui.test.mjs b/apps/android/scripts/setup-ui.test.mjs index 413b2f2e..b7477c26 100644 --- a/apps/android/scripts/setup-ui.test.mjs +++ b/apps/android/scripts/setup-ui.test.mjs @@ -106,6 +106,28 @@ test('settings list every place, mark the one in use and switch to the one tappe } finally { view.close(); } }); +test('SSH computers are offered only by builds that can install on them, and switch like the others', () => { + assert.equal(setup().document.querySelectorAll('[data-action="ssh_add"]').length, 0); + const welcome = setup({ sshAvailable: true }); + try { + welcome.document.querySelector('[data-action="ssh_add"]').click(); + assert.deepEqual(welcome.actions.at(-1), { action: 'ssh_add' }); + } finally { welcome.close(); } + const sshComputers = [ + { id: 'c'.repeat(32), name: 'Devbox', destination: 'dev@devbox', current: true }, + { id: 'd'.repeat(32), name: 'Lab', destination: 'dev@lab:2222', current: false }, + ]; + const view = setup({ initial: 'settings', remote: true, computer: 'Devbox', sshAvailable: true, sshComputers, computers: [] }); + try { + assert.equal(view.document.querySelector('.row.current strong').textContent, 'Devbox'); + assert.equal(view.document.querySelectorAll('b').length, 0); + view.document.querySelector('[data-action="use_ssh"]').click(); + assert.deepEqual(view.actions.at(-1), { action: 'use_ssh', id: 'd'.repeat(32) }); + // Its work is watched from this phone, so background work stays a setting here. + assert.ok(view.document.querySelector('[data-action="background_screen"]')); + } finally { view.close(); } +}); + test('unsupported phones offer a remote workspace instead of repeated installation', () => { const view = setup({ initial: 'local', checks: { ...ready, arm64: false } }); try { diff --git a/apps/desktop/frontend/desktopBootstrap.ts b/apps/desktop/frontend/desktopBootstrap.ts index 62c90205..f9f42352 100644 --- a/apps/desktop/frontend/desktopBootstrap.ts +++ b/apps/desktop/frontend/desktopBootstrap.ts @@ -8,7 +8,7 @@ export type LocalHttpConnection = { * Addresses another App Server HTTP route beside the RPC endpoint, which ends in `/probe`. * A paired computer's gateway keeps the same layout under its own base path. */ -export function localHttpRouteUrl(endpointUrl: string, route: "download" | "upload") { +export function localHttpRouteUrl(endpointUrl: string, route: "download" | "upload" | "terminal") { let url: URL; try { url = new URL(endpointUrl); diff --git a/apps/desktop/frontend/desktopComputers.test.ts b/apps/desktop/frontend/desktopComputers.test.ts index 5fa593aa..4b84dd09 100644 --- a/apps/desktop/frontend/desktopComputers.test.ts +++ b/apps/desktop/frontend/desktopComputers.test.ts @@ -86,4 +86,23 @@ describe("Desktop computers", () => { expect(reload).toHaveBeenCalledOnce(); expect(invoke.mock.calls.filter(([command]) => command === "desktop_remote_snapshot")).toHaveLength(2); }); + + it("adds an SSH computer as a switch, rereading the list when the window stayed", async () => { + const reload = vi.fn(async () => undefined); + const outcomes = ["alreadyInUse", "switched"]; + const invoke = invoker({ + desktop_remote_snapshot: async () => [studio], + desktop_ssh_add: async () => outcomes.shift(), + }); + const computers = createDesktopComputers(invoke, reload); + await vi.waitFor(() => expect(computers.snapshot()).toBeDefined()); + + await expect(computers.ssh!.add("devbox")).resolves.toBe("unchanged"); + expect(reload).not.toHaveBeenCalled(); + await expect(computers.ssh!.add("me@lab", "Lab")).resolves.toBe("switched"); + + expect(reload).toHaveBeenCalledTimes(1); + expect(invoke).toHaveBeenCalledWith("desktop_ssh_add", { destination: "me@lab", name: "Lab", operationId: null }); + expect((invoke as unknown as { mock: { calls: unknown[][] } }).mock.calls.filter(([command]) => command === "desktop_remote_snapshot")).toHaveLength(2); + }); }); diff --git a/apps/desktop/frontend/desktopComputers.ts b/apps/desktop/frontend/desktopComputers.ts index 69567807..721f6acc 100644 --- a/apps/desktop/frontend/desktopComputers.ts +++ b/apps/desktop/frontend/desktopComputers.ts @@ -1,11 +1,11 @@ import type { invoke as tauriInvoke } from "@tauri-apps/api/core"; -import type { ComputerSwitchResult, ComputersCapability, ComputersSnapshot, ShellComputer } from "../../../packages/frontend/src/services/computers"; +import type { ComputerSwitchResult, ComputersCapability, ComputersSnapshot, ShellComputer, SshComputerStatus } from "../../../packages/frontend/src/services/computers"; /** What the native side reports for a switch it was asked for. */ type NativeSwitchOutcome = "switched" | "alreadyInUse" | "cancelled"; /** - * The Desktop's paired computers (ADR-0063), kept by the native side in its + * The Desktop's paired computers (ADR-0063) and SSH Computers (ADR-0069), kept by the native side in its * bootstrap preferences. The native side connects the chosen computer first and * keeps this computer's App Server running (ADR-0070); once it switched, the * window leaves its App Server and reloads onto the new one. @@ -55,6 +55,18 @@ export function createDesktopComputers( async pair(code) { if (await settle(await invoke("desktop_remote_pair", { code, operationId: null })) !== "switched") await read(); }, + // SSH Computers (ADR-0069) are rows of the same list, marked by their `ssh` destination. + ssh: { + hosts: () => invoke("desktop_ssh_hosts"), + async add(destination, name) { + const result = await settle(await invoke("desktop_ssh_add", { destination, name: name ?? null, operationId: null })); + if (result !== "switched") await read(); + return result; + }, + status: async () => (await invoke("desktop_ssh_status")) ?? undefined, + update: () => invoke("desktop_ssh_update"), + stop: (id) => invoke<"stopped" | "notRunning" | "stillRunning">("desktop_ssh_stop", { id }), + }, }; async function settle(outcome: NativeSwitchOutcome): Promise { diff --git a/apps/desktop/frontend/desktopShell.ts b/apps/desktop/frontend/desktopShell.ts index 7cd9cab8..8cb919d7 100644 --- a/apps/desktop/frontend/desktopShell.ts +++ b/apps/desktop/frontend/desktopShell.ts @@ -23,7 +23,7 @@ import { createDesktopProjectPicker } from "./desktopProjectPicker"; import { createDesktopServerRecovery } from "./desktopServerRecovery"; import { createDesktopComputers } from "./desktopComputers"; import { createDesktopUpdates } from "./desktopUpdates"; -import type { DesktopBootstrap, LocalHttpConnection } from "./desktopBootstrap"; +import { localHttpRouteUrl, type DesktopBootstrap, type LocalHttpConnection } from "./desktopBootstrap"; import { desktopCommandForKeyboardEvent, type DesktopCommand, type DesktopSurfaceCommand } from "./desktopCommands"; import { createDesktopSupportExports } from "./desktopSupportExports"; import { createDesktopFileViewerContent, desktopPdfRendererDocumentUrl } from "./desktopFileViewer"; @@ -57,6 +57,8 @@ export function createDesktopShell( // subscription lives as long as the WebView. Native focus is the reliable // machine-resume signal on macOS, and re-broadcasting it as `openaide:resume` // also wakes the shared transport and restarts suspended animations. + // This computer's App Server, and an SSH Computer's forward and App Server, + // are this shell's to restore; a paired computer's gateway reopens on its own. const remote = Boolean(host.remoteComputer); createDesktopServerRecovery({ invoke }); void nativeWindow.onFocusChanged(({ payload: focused }) => { @@ -370,6 +372,18 @@ export function createDesktopShell( openExternal: (url) => { void openUrl(url); }, reload: () => window.location.reload(), }, + terminals: { + socketUrl() { + const url = localHttpRouteUrl(connection().endpointUrl, "terminal"); + if (!url) throw new Error("The App Server endpoint has no Terminal route."); + url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; + return url.toString(); + }, + // The native Quit asks before closing Terminals, from this count (`desktop_quit.rs`). + openCountChanged(count) { + void invoke("desktop_set_open_terminals", { count }).catch(() => undefined); + }, + }, fileViewer: true, fileViewerContent: createDesktopFileViewerContent(connection, host.clientInstanceId), pdfRendererDocumentUrl: desktopPdfRendererDocumentUrl(host.platform), diff --git a/apps/desktop/frontend/desktopSshPrompts.test.ts b/apps/desktop/frontend/desktopSshPrompts.test.ts new file mode 100644 index 00000000..ee419855 --- /dev/null +++ b/apps/desktop/frontend/desktopSshPrompts.test.ts @@ -0,0 +1,69 @@ +// @vitest-environment jsdom +import { afterEach, expect, it, vi } from "vitest"; +import { initializeSshPrompts, type DesktopSshPrompt } from "./desktopSshPrompts"; + +afterEach(() => { + document.body.innerHTML = ""; + vi.restoreAllMocks(); +}); + +const password: DesktopSshPrompt = { id: 1, computer: "devbox", text: "me@devbox's password:", kind: "secret" }; +const hostKey: DesktopSshPrompt = { id: 2, computer: "devbox", text: "Are you sure you want to continue connecting (yes/no)?", kind: "yesNo" }; + +async function setUp(pending: DesktopSshPrompt[] = []) { + let emit: (prompt: DesktopSshPrompt) => void = () => undefined; + const invoke = vi.fn(async (command: string) => (command === "desktop_ssh_prompts" ? pending : true)); + const listen = vi.fn(async (_event: string, listener: (event: { payload: DesktopSshPrompt }) => void) => { + emit = (payload) => listener({ payload }); + return () => undefined; + }); + vi.spyOn(console, "info").mockImplementation(() => undefined); + initializeSshPrompts({ invoke, listen }); + await vi.waitFor(() => expect(invoke).toHaveBeenCalledWith("desktop_ssh_prompts")); + return { invoke, emit: (prompt: DesktopSshPrompt) => emit(prompt) }; +} + +const prompt = () => document.querySelector(".desktop-ssh-prompt"); +const press = (label: string) => [...prompt()!.querySelectorAll("button")].find((button) => button.textContent === label)!.click(); + +it("asks for a password that was pending before the WebView listened, and logs no answer", async () => { + const { invoke } = await setUp([password]); + + expect(prompt()?.textContent).toContain("me@devbox's password:"); + const input = prompt()!.querySelector("input")!; + expect(input.type).toBe("password"); + input.value = "hunter2"; + press("Continue"); + + expect(invoke).toHaveBeenCalledWith("desktop_ssh_prompt_answer", { id: 1, answer: "hunter2" }); + expect(prompt()).toBeNull(); + await vi.waitFor(() => expect(console.info).toHaveBeenCalledWith(expect.stringContaining("desktop_ssh_prompt_closed prompt_id=1"))); + expect(JSON.stringify(vi.mocked(console.info).mock.calls)).not.toContain("hunter2"); +}); + +it("shows one question at a time, ignores a repeat, and answers a host key question", async () => { + const { invoke, emit } = await setUp(); + emit(hostKey); + emit(password); + emit(hostKey); + + expect(document.querySelectorAll(".desktop-ssh-prompt")).toHaveLength(1); + press("No"); + expect(invoke).toHaveBeenCalledWith("desktop_ssh_prompt_answer", { id: 2, answer: "no" }); + expect(prompt()?.querySelector("input")).not.toBeNull(); + press("Cancel"); + + expect(invoke).toHaveBeenCalledWith("desktop_ssh_prompt_answer", { id: 1, answer: null }); + expect(prompt()).toBeNull(); +}); + +it("dismisses a notice without answering ssh", async () => { + const { invoke, emit } = await setUp(); + emit({ id: 3, computer: "devbox", text: "Host key changed.", kind: "notice" }); + + expect(prompt()?.textContent).toContain("Message from devbox"); + press("OK"); + + expect(prompt()).toBeNull(); + expect(invoke).not.toHaveBeenCalledWith("desktop_ssh_prompt_answer", expect.anything()); +}); diff --git a/apps/desktop/frontend/desktopSshPrompts.ts b/apps/desktop/frontend/desktopSshPrompts.ts new file mode 100644 index 00000000..0dd761b4 --- /dev/null +++ b/apps/desktop/frontend/desktopSshPrompts.ts @@ -0,0 +1,140 @@ +/** One question from `ssh`, relayed by the Desktop's askpass broker (ADR-0069). */ +export type DesktopSshPrompt = { + id: number; + /** The SSH Computer's name in this Desktop. */ + computer: string; + /** `ssh`'s own wording, such as a password request or a host key fingerprint. */ + text: string; + kind: "secret" | "yesNo" | "notice"; +}; + +type Invoke = (command: string, args?: Record) => Promise; +type Listen = ( + event: string, + listener: (event: { payload: DesktopSshPrompt }) => void, +) => Promise<() => void>; + +/** + * Shows `ssh` questions one at a time, above the startup screen and the app + * alike, since `ssh` may ask while the shell starts or while it reconnects a + * forward. The dialog lives on `document.body`, outside the React root that + * replaces the startup nodes. Answers go straight back to the broker and are + * never kept here; logs carry only the prompt id and kind. + */ +export function initializeSshPrompts({ + invoke, + listen, + body = document.body, +}: { + invoke: Invoke; + listen: Listen; + body?: HTMLElement; +}) { + const queue: DesktopSshPrompt[] = []; + const seen = new Set(); + let shown: { prompt: DesktopSshPrompt; element: HTMLElement; startedAt: number } | undefined; + + const enqueue = (prompt: DesktopSshPrompt) => { + if (seen.has(prompt.id)) return; + seen.add(prompt.id); + queue.push(prompt); + if (!shown) showNext(); + }; + + const finish = (answer: string | null, outcome: "answered" | "declined" | "dismissed") => { + if (!shown) return; + const { prompt, element, startedAt } = shown; + shown = undefined; + element.remove(); + const durationMs = Math.round(performance.now() - startedAt); + if (prompt.kind === "notice") { + console.info(`desktop_ssh_prompt_closed prompt_id=${prompt.id} kind=notice outcome=${outcome} duration_ms=${durationMs}`); + } else { + void invoke("desktop_ssh_prompt_answer", { id: prompt.id, answer }) + .then((delivered) => { + // `false` means `ssh` stopped waiting, such as after the broker's timeout. + console.info(`desktop_ssh_prompt_closed prompt_id=${prompt.id} kind=${prompt.kind} outcome=${outcome} delivered=${delivered === true} duration_ms=${durationMs}`); + }) + .catch(() => { + console.warn(`desktop_ssh_prompt_closed prompt_id=${prompt.id} kind=${prompt.kind} outcome=failure error_kind=answer_delivery duration_ms=${durationMs}`); + }); + } + showNext(); + }; + + const showNext = () => { + const prompt = queue.shift(); + if (!prompt) return; + const element = renderPrompt(prompt, finish); + shown = { prompt, element, startedAt: performance.now() }; + body.append(element); + element.querySelector("input, button[data-primary]")?.focus(); + console.info(`desktop_ssh_prompt_shown prompt_id=${prompt.id} kind=${prompt.kind}`); + }; + + // Listen first, then read what arrived before; ids drop the overlap. + void listen("desktop-ssh-prompt", ({ payload }) => enqueue(payload)) + .then(() => invoke("desktop_ssh_prompts")) + .then((pending) => { for (const prompt of pending as DesktopSshPrompt[]) enqueue(prompt); }) + .catch(() => console.warn("desktop_ssh_prompts_load_completed outcome=failure error_kind=native_command")); +} + +function renderPrompt( + prompt: DesktopSshPrompt, + finish: (answer: string | null, outcome: "answered" | "declined" | "dismissed") => void, +) { + const form = document.createElement("form"); + form.className = "desktop-ssh-prompt"; + form.setAttribute("role", "dialog"); + form.setAttribute("aria-modal", "true"); + form.setAttribute("aria-label", `${prompt.computer} asks`); + const title = document.createElement("strong"); + title.textContent = prompt.kind === "notice" ? `Message from ${prompt.computer}` : `${prompt.computer} asks`; + const text = document.createElement("pre"); + text.textContent = prompt.text; + const footer = document.createElement("footer"); + form.append(title, text); + + const button = (label: string, primary: boolean, onClick: () => void) => { + const element = document.createElement("button"); + element.type = primary ? "submit" : "button"; + element.textContent = label; + if (primary) element.dataset.primary = ""; + else element.onclick = onClick; + footer.append(element); + }; + + if (prompt.kind === "secret") { + const input = document.createElement("input"); + input.type = "password"; + input.autocomplete = "off"; + input.setAttribute("aria-label", "Answer"); + form.append(input); + button("Cancel", false, () => finish(null, "declined")); + button("Continue", true, () => undefined); + form.onsubmit = (event) => { + event.preventDefault(); + finish(input.value, "answered"); + }; + } else if (prompt.kind === "yesNo") { + button("No", false, () => finish("no", "declined")); + button("Yes", true, () => undefined); + form.onsubmit = (event) => { + event.preventDefault(); + finish("yes", "answered"); + }; + } else { + button("OK", true, () => undefined); + form.onsubmit = (event) => { + event.preventDefault(); + finish(null, "dismissed"); + }; + } + form.onkeydown = (event) => { + if (event.key !== "Escape") return; + event.preventDefault(); + finish(prompt.kind === "yesNo" ? "no" : null, prompt.kind === "notice" ? "dismissed" : "declined"); + }; + form.append(footer); + return form; +} diff --git a/apps/desktop/frontend/desktopSshStartup.test.ts b/apps/desktop/frontend/desktopSshStartup.test.ts new file mode 100644 index 00000000..e4c01a4e --- /dev/null +++ b/apps/desktop/frontend/desktopSshStartup.test.ts @@ -0,0 +1,33 @@ +// @vitest-environment jsdom +import startupHtml from "../index.html?raw"; +import { afterEach, expect, it, vi } from "vitest"; +import { offerSshUpdateAfterFailure } from "./desktopSshStartup"; + +afterEach(() => vi.restoreAllMocks()); + +function setUp(failure: unknown) { + document.documentElement.innerHTML = startupHtml; + vi.spyOn(console, "info").mockImplementation(() => undefined); + const invoke = vi.fn(async (command: string) => (command === "desktop_ssh_failure" ? failure : undefined)); + return { invoke, root: document.getElementById("root")!, update: document.querySelector(".desktop-startup-update")! }; +} + +it("updates an SSH Computer that is too old only after a second, warned click", async () => { + const { invoke, root, update } = setUp({ computerId: "ssh-1", kind: "updateRequired", runningVersion: "0.8.0" }); + await offerSshUpdateAfterFailure({ invoke, root, computer: "devbox" }); + + expect(update.hidden).toBe(false); + update.click(); + expect(root.querySelector(".desktop-startup-label")?.textContent).toContain("agents working there stop"); + expect(invoke).not.toHaveBeenCalledWith("desktop_ssh_update"); + update.click(); + + expect(invoke).toHaveBeenCalledWith("desktop_ssh_update"); +}); + +it("offers nothing when the start failed for another reason", async () => { + const { invoke, root, update } = setUp({ computerId: "ssh-1", kind: "failed" }); + await offerSshUpdateAfterFailure({ invoke, root, computer: "devbox" }); + + expect(update.hidden).toBe(true); +}); diff --git a/apps/desktop/frontend/desktopSshStartup.ts b/apps/desktop/frontend/desktopSshStartup.ts new file mode 100644 index 00000000..b4db800d --- /dev/null +++ b/apps/desktop/frontend/desktopSshStartup.ts @@ -0,0 +1,55 @@ +type Invoke = (command: string, args?: Record) => Promise; + +type SshFailure = { + computerId: string; + kind: "updateRequired" | "failed"; + runningVersion?: string; +}; + +/** + * After a failed start, offers the one way past an SSH Computer whose App + * Server is too old for this app: replacing it. That App Server refuses this + * client, so the warning cannot list what it runs; it names what may be lost + * and asks once more before the shell replaces it on the next start. + */ +export async function offerSshUpdateAfterFailure({ + invoke, + root, + computer, +}: { + invoke: Invoke; + root: HTMLElement; + computer: string; +}) { + let failure: SshFailure | null; + try { + failure = await invoke("desktop_ssh_failure") as SshFailure | null; + } catch { + console.warn("desktop_ssh_failure_load_completed outcome=failure error_kind=native_command"); + return; + } + if (failure?.kind !== "updateRequired") return; + const update = root.querySelector(".desktop-startup-update"); + const label = root.querySelector(".desktop-startup-label"); + if (!update || !label) return; + update.hidden = false; + let confirming = false; + update.onclick = () => { + if (!confirming) { + confirming = true; + label.textContent = `Updating restarts OpenAIDE on ${computer}. Other apps connected to it disconnect, and agents working there stop.`; + update.textContent = "Update and restart"; + return; + } + update.disabled = true; + update.textContent = "Updating…"; + console.info("desktop_ssh_update_requested source=startup"); + // Success restarts the app; the shell's own logs carry the outcome. + void invoke("desktop_ssh_update").catch((error: unknown) => { + update.disabled = false; + update.textContent = "Update and restart"; + label.textContent = typeof error === "string" ? error : "OpenAIDE could not update that computer."; + console.warn("desktop_ssh_update_requested outcome=failure error_kind=native_command"); + }); + }; +} diff --git a/apps/desktop/frontend/desktopStartupWindow.test.ts b/apps/desktop/frontend/desktopStartupWindow.test.ts index 483ad64c..e795b29b 100644 --- a/apps/desktop/frontend/desktopStartupWindow.test.ts +++ b/apps/desktop/frontend/desktopStartupWindow.test.ts @@ -15,6 +15,12 @@ vi.mock("@tauri-apps/api/event", () => ({ listen: vi.fn(async () => () => {}) }) vi.mock("../../../packages/frontend/src/startFrontend", () => ({ startFrontend: vi.fn() })); vi.mock("./desktopShell", () => ({ createDesktopShell: vi.fn() })); +/** Fails the startup handshake while other shell commands stay pending. */ +async function rejectStartup(reason: unknown) { + const { invoke } = await import("@tauri-apps/api/core"); + vi.mocked(invoke).mockImplementation((command) => (command === "desktop_bootstrap_context" ? Promise.reject(reason) : new Promise(() => {}))); +} + afterEach(() => { vi.useRealTimers(); vi.clearAllMocks(); vi.resetModules(); }); it("can drag and use caption buttons while backend startup is pending", async () => { @@ -50,8 +56,7 @@ it("keeps native window controls on decorated platforms", async () => { it("keeps close available after startup fails", async () => { vi.useFakeTimers(); - const { invoke } = await import("@tauri-apps/api/core"); - vi.mocked(invoke).mockRejectedValueOnce(new Error("Startup unavailable")); + await rejectStartup(new Error("Startup unavailable")); document.documentElement.innerHTML = startupHtml; await import("./main"); await vi.waitFor(() => expect(document.querySelector(".desktop-startup-label")!.textContent).toContain("could not start")); @@ -63,8 +68,7 @@ it("keeps close available after startup fails", async () => { it("shows the reason a rejected startup command reports", async () => { vi.useFakeTimers(); - const { invoke } = await import("@tauri-apps/api/core"); - vi.mocked(invoke).mockRejectedValueOnce("The App Server did not become ready in time."); + await rejectStartup("The App Server did not become ready in time."); document.documentElement.innerHTML = startupHtml; await import("./main"); await vi.waitFor(() => expect(document.querySelector(".desktop-startup-label")!.textContent) diff --git a/apps/desktop/frontend/main.tsx b/apps/desktop/frontend/main.tsx index 8ac379be..3cdd3ea1 100644 --- a/apps/desktop/frontend/main.tsx +++ b/apps/desktop/frontend/main.tsx @@ -9,20 +9,27 @@ import { } from "./desktopBootstrap"; import { refuseUnclaimedFileDrops } from "./desktopFileUploads"; +import { initializeSshPrompts } from "./desktopSshPrompts"; +import { offerSshUpdateAfterFailure } from "./desktopSshStartup"; import { initializeStartupWindow } from "./desktopStartupWindow"; refuseUnclaimedFileDrops(window); initializeStartupWindow(); +// `ssh` may ask for a password or a host key decision during startup. +initializeSshPrompts({ invoke, listen }); const startupStartedAt = performance.now(); const startupElapsedTimer = window.setInterval(updateStartupElapsed, 1_000); +let remoteComputer: string | undefined; + async function start() { const bootstrap = await runDesktopBootstrap({ invoke, listen, onProgress: ({ message }) => updateStartupStatus(message), onContext: (context) => { + remoteComputer = context.remoteComputer; const cancel = document.querySelector(".desktop-startup-cancel"); if (cancel) { // A paired computer may be off or out of reach; the way back to this @@ -85,5 +92,6 @@ void start().catch((error) => { retry.hidden = false; retry.onclick = () => window.location.reload(); } + if (remoteComputer) void offerSshUpdateAfterFailure({ invoke, root, computer: remoteComputer }); } }); diff --git a/apps/desktop/index.html b/apps/desktop/index.html index b5df12ec..babceaa1 100644 --- a/apps/desktop/index.html +++ b/apps/desktop/index.html @@ -172,6 +172,67 @@ padding: 4px 10px; } + /* `ssh` questions; above the startup screen and the app (desktopSshPrompts.ts). */ + .desktop-ssh-prompt { + position: fixed; + z-index: 2147483000; + inset: 50% auto auto 50%; + transform: translate(-50%, -50%); + width: min(440px, calc(100vw - 32px)); + box-sizing: border-box; + display: grid; + gap: 10px; + margin: 0; + padding: 18px; + border: 1px solid light-dark(oklch(0.84 0.01 255), oklch(0.36 0.012 255)); + border-radius: 10px; + background: light-dark(oklch(0.99 0.004 255), oklch(0.23 0.009 255)); + color: light-dark(oklch(0.24 0.012 255), oklch(0.91 0.007 255)); + box-shadow: 0 0 0 100vmax rgb(0 0 0 / 32%), 0 12px 32px rgb(0 0 0 / 24%); + font: 13px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI Variable", "Segoe UI", system-ui, sans-serif; + } + + .desktop-ssh-prompt pre { + max-height: 40vh; + overflow: auto; + margin: 0; + white-space: pre-wrap; + overflow-wrap: anywhere; + font: 12px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + } + + .desktop-ssh-prompt input { + min-height: 30px; + border: 1px solid light-dark(oklch(0.72 0.012 255), oklch(0.43 0.012 255)); + border-radius: 6px; + background: transparent; + color: inherit; + padding: 4px 8px; + font: inherit; + } + + .desktop-ssh-prompt footer { + display: flex; + justify-content: flex-end; + gap: 8px; + } + + .desktop-ssh-prompt button { + min-height: 30px; + border: 1px solid light-dark(oklch(0.72 0.012 255), oklch(0.43 0.012 255)); + border-radius: 6px; + background: light-dark(oklch(0.99 0.004 255), oklch(0.245 0.009 255)); + color: inherit; + padding: 4px 12px; + font: inherit; + } + + .desktop-ssh-prompt button[data-primary] { + border-color: #4d9cff; + background: #4d9cff; + color: white; + } + @keyframes desktop-startup-shine { 0%, 28% { transform: translateX(-115%); } 72%, 100% { transform: translateX(115%); } @@ -228,6 +289,7 @@
+
diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index d8f9976d..1e17b3c1 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -3740,6 +3740,7 @@ dependencies = [ "tauri-plugin-notification", "tauri-plugin-opener", "tauri-plugin-updater", + "tempfile", "tokio", "url", "uuid", @@ -3750,10 +3751,13 @@ name = "openaide-remote-client" version = "0.0.0" dependencies = [ "data-encoding", + "flate2", "iroh", "openaide-app-server-protocol", "serde", "serde_json", + "sha2 0.10.9", + "tar", "thiserror 2.0.20", "tokio", ] diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index ddc78830..6a3984d4 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -16,8 +16,9 @@ futures-util = "0.3" fs2 = "0.4" # Export builds its own client; the updater's rustls-no-provider feature alone # panics here unless another component happened to install a global provider. -# The client half of Remote Devices, shared with the App Server's workspace (ADR-0063). -openaide-remote-client = { path = "../../../openaide-rs/remote-client" } +# The client half of Remote Devices, shared with the App Server's workspace +# (ADR-0063), and the SSH Computer launcher with its release unpacking (ADR-0069). +openaide-remote-client = { path = "../../../openaide-rs/remote-client", features = ["release-archive"] } reqwest = { version = "0.13.4", default-features = false, features = ["rustls"] } semver = "1" serde = { version = "1", features = ["derive"] } @@ -32,6 +33,9 @@ tokio = { version = "1", features = ["sync"] } url = "2" uuid = { version = "1", features = ["v4"] } +[dev-dependencies] +tempfile = "3" + [target.'cfg(any(target_os = "macos", target_os = "windows"))'.dependencies] keyring = { version = "3.6.3", features = ["apple-native", "windows-native"] } diff --git a/apps/desktop/src-tauri/build.rs b/apps/desktop/src-tauri/build.rs index 0c172218..b4291a96 100644 --- a/apps/desktop/src-tauri/build.rs +++ b/apps/desktop/src-tauri/build.rs @@ -4,6 +4,7 @@ fn main() { "OPENAIDE_DESKTOP_UPDATE_RELEASE_PUBLIC_KEY", "OPENAIDE_DESKTOP_UPDATE_RECOVERY_PUBLIC_KEY", "OPENAIDE_DESKTOP_UPDATE_ENABLED_BUILD", + "OPENAIDE_SSH_APP_SERVER_SHA256", ] { println!("cargo:rerun-if-env-changed={variable}"); } diff --git a/apps/desktop/src-tauri/gen/schemas/acl-manifests.json b/apps/desktop/src-tauri/gen/schemas/acl-manifests.json index ddf8ddc2..834ce694 100644 --- a/apps/desktop/src-tauri/gen/schemas/acl-manifests.json +++ b/apps/desktop/src-tauri/gen/schemas/acl-manifests.json @@ -1 +1 @@ -{"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener","allow-supports-multiple-windows"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-supports-multiple-windows":{"identifier":"allow-supports-multiple-windows","description":"Enables the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":["supports_multiple_windows"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-data-store":{"identifier":"deny-remove-data-store","description":"Denies the remove_data_store command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_data_store"]}},"deny-remove-listener":{"identifier":"deny-remove-listener","description":"Denies the remove_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_listener"]}},"deny-set-app-theme":{"identifier":"deny-set-app-theme","description":"Denies the set_app_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_app_theme"]}},"deny-set-dock-visibility":{"identifier":"deny-set-dock-visibility","description":"Denies the set_dock_visibility command without any pre-configured scope.","commands":{"allow":[],"deny":["set_dock_visibility"]}},"deny-supports-multiple-windows":{"identifier":"deny-supports-multiple-windows","description":"Denies the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["supports_multiple_windows"]}},"deny-tauri-version":{"identifier":"deny-tauri-version","description":"Denies the tauri_version command without any pre-configured scope.","commands":{"allow":[],"deny":["tauri_version"]}},"deny-version":{"identifier":"deny-version","description":"Denies the version command without any pre-configured scope.","commands":{"allow":[],"deny":["version"]}}},"permission_sets":{},"global_scope_schema":null},"core:event":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-listen","allow-unlisten","allow-emit","allow-emit-to"]},"permissions":{"allow-emit":{"identifier":"allow-emit","description":"Enables the emit command without any pre-configured scope.","commands":{"allow":["emit"],"deny":[]}},"allow-emit-to":{"identifier":"allow-emit-to","description":"Enables the emit_to command without any pre-configured scope.","commands":{"allow":["emit_to"],"deny":[]}},"allow-listen":{"identifier":"allow-listen","description":"Enables the listen command without any pre-configured scope.","commands":{"allow":["listen"],"deny":[]}},"allow-unlisten":{"identifier":"allow-unlisten","description":"Enables the unlisten command without any pre-configured scope.","commands":{"allow":["unlisten"],"deny":[]}},"deny-emit":{"identifier":"deny-emit","description":"Denies the emit command without any pre-configured scope.","commands":{"allow":[],"deny":["emit"]}},"deny-emit-to":{"identifier":"deny-emit-to","description":"Denies the emit_to command without any pre-configured scope.","commands":{"allow":[],"deny":["emit_to"]}},"deny-listen":{"identifier":"deny-listen","description":"Denies the listen command without any pre-configured scope.","commands":{"allow":[],"deny":["listen"]}},"deny-unlisten":{"identifier":"deny-unlisten","description":"Denies the unlisten command without any pre-configured scope.","commands":{"allow":[],"deny":["unlisten"]}}},"permission_sets":{},"global_scope_schema":null},"core:image":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-from-bytes","allow-from-path","allow-rgba","allow-size"]},"permissions":{"allow-from-bytes":{"identifier":"allow-from-bytes","description":"Enables the from_bytes command without any pre-configured scope.","commands":{"allow":["from_bytes"],"deny":[]}},"allow-from-path":{"identifier":"allow-from-path","description":"Enables the from_path command without any pre-configured scope.","commands":{"allow":["from_path"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-rgba":{"identifier":"allow-rgba","description":"Enables the rgba command without any pre-configured scope.","commands":{"allow":["rgba"],"deny":[]}},"allow-size":{"identifier":"allow-size","description":"Enables the size command without any pre-configured scope.","commands":{"allow":["size"],"deny":[]}},"deny-from-bytes":{"identifier":"deny-from-bytes","description":"Denies the from_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["from_bytes"]}},"deny-from-path":{"identifier":"deny-from-path","description":"Denies the from_path command without any pre-configured scope.","commands":{"allow":[],"deny":["from_path"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-rgba":{"identifier":"deny-rgba","description":"Denies the rgba command without any pre-configured scope.","commands":{"allow":[],"deny":["rgba"]}},"deny-size":{"identifier":"deny-size","description":"Denies the size command without any pre-configured scope.","commands":{"allow":[],"deny":["size"]}}},"permission_sets":{},"global_scope_schema":null},"core:menu":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-append","allow-prepend","allow-insert","allow-remove","allow-remove-at","allow-items","allow-get","allow-popup","allow-create-default","allow-set-as-app-menu","allow-set-as-window-menu","allow-text","allow-set-text","allow-is-enabled","allow-set-enabled","allow-set-accelerator","allow-set-as-windows-menu-for-nsapp","allow-set-as-help-menu-for-nsapp","allow-is-checked","allow-set-checked","allow-set-icon"]},"permissions":{"allow-append":{"identifier":"allow-append","description":"Enables the append command without any pre-configured scope.","commands":{"allow":["append"],"deny":[]}},"allow-create-default":{"identifier":"allow-create-default","description":"Enables the create_default command without any pre-configured scope.","commands":{"allow":["create_default"],"deny":[]}},"allow-get":{"identifier":"allow-get","description":"Enables the get command without any pre-configured scope.","commands":{"allow":["get"],"deny":[]}},"allow-insert":{"identifier":"allow-insert","description":"Enables the insert command without any pre-configured scope.","commands":{"allow":["insert"],"deny":[]}},"allow-is-checked":{"identifier":"allow-is-checked","description":"Enables the is_checked command without any pre-configured scope.","commands":{"allow":["is_checked"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-items":{"identifier":"allow-items","description":"Enables the items command without any pre-configured scope.","commands":{"allow":["items"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-popup":{"identifier":"allow-popup","description":"Enables the popup command without any pre-configured scope.","commands":{"allow":["popup"],"deny":[]}},"allow-prepend":{"identifier":"allow-prepend","description":"Enables the prepend command without any pre-configured scope.","commands":{"allow":["prepend"],"deny":[]}},"allow-remove":{"identifier":"allow-remove","description":"Enables the remove command without any pre-configured scope.","commands":{"allow":["remove"],"deny":[]}},"allow-remove-at":{"identifier":"allow-remove-at","description":"Enables the remove_at command without any pre-configured scope.","commands":{"allow":["remove_at"],"deny":[]}},"allow-set-accelerator":{"identifier":"allow-set-accelerator","description":"Enables the set_accelerator command without any pre-configured scope.","commands":{"allow":["set_accelerator"],"deny":[]}},"allow-set-as-app-menu":{"identifier":"allow-set-as-app-menu","description":"Enables the set_as_app_menu command without any pre-configured scope.","commands":{"allow":["set_as_app_menu"],"deny":[]}},"allow-set-as-help-menu-for-nsapp":{"identifier":"allow-set-as-help-menu-for-nsapp","description":"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_help_menu_for_nsapp"],"deny":[]}},"allow-set-as-window-menu":{"identifier":"allow-set-as-window-menu","description":"Enables the set_as_window_menu command without any pre-configured scope.","commands":{"allow":["set_as_window_menu"],"deny":[]}},"allow-set-as-windows-menu-for-nsapp":{"identifier":"allow-set-as-windows-menu-for-nsapp","description":"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_windows_menu_for_nsapp"],"deny":[]}},"allow-set-checked":{"identifier":"allow-set-checked","description":"Enables the set_checked command without any pre-configured scope.","commands":{"allow":["set_checked"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-text":{"identifier":"allow-set-text","description":"Enables the set_text command without any pre-configured scope.","commands":{"allow":["set_text"],"deny":[]}},"allow-text":{"identifier":"allow-text","description":"Enables the text command without any pre-configured scope.","commands":{"allow":["text"],"deny":[]}},"deny-append":{"identifier":"deny-append","description":"Denies the append command without any pre-configured scope.","commands":{"allow":[],"deny":["append"]}},"deny-create-default":{"identifier":"deny-create-default","description":"Denies the create_default command without any pre-configured scope.","commands":{"allow":[],"deny":["create_default"]}},"deny-get":{"identifier":"deny-get","description":"Denies the get command without any pre-configured scope.","commands":{"allow":[],"deny":["get"]}},"deny-insert":{"identifier":"deny-insert","description":"Denies the insert command without any pre-configured scope.","commands":{"allow":[],"deny":["insert"]}},"deny-is-checked":{"identifier":"deny-is-checked","description":"Denies the is_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["is_checked"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-items":{"identifier":"deny-items","description":"Denies the items command without any pre-configured scope.","commands":{"allow":[],"deny":["items"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-popup":{"identifier":"deny-popup","description":"Denies the popup command without any pre-configured scope.","commands":{"allow":[],"deny":["popup"]}},"deny-prepend":{"identifier":"deny-prepend","description":"Denies the prepend command without any pre-configured scope.","commands":{"allow":[],"deny":["prepend"]}},"deny-remove":{"identifier":"deny-remove","description":"Denies the remove command without any pre-configured scope.","commands":{"allow":[],"deny":["remove"]}},"deny-remove-at":{"identifier":"deny-remove-at","description":"Denies the remove_at command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_at"]}},"deny-set-accelerator":{"identifier":"deny-set-accelerator","description":"Denies the set_accelerator command without any pre-configured scope.","commands":{"allow":[],"deny":["set_accelerator"]}},"deny-set-as-app-menu":{"identifier":"deny-set-as-app-menu","description":"Denies the set_as_app_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_app_menu"]}},"deny-set-as-help-menu-for-nsapp":{"identifier":"deny-set-as-help-menu-for-nsapp","description":"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_help_menu_for_nsapp"]}},"deny-set-as-window-menu":{"identifier":"deny-set-as-window-menu","description":"Denies the set_as_window_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_window_menu"]}},"deny-set-as-windows-menu-for-nsapp":{"identifier":"deny-set-as-windows-menu-for-nsapp","description":"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_windows_menu_for_nsapp"]}},"deny-set-checked":{"identifier":"deny-set-checked","description":"Denies the set_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["set_checked"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-text":{"identifier":"deny-set-text","description":"Denies the set_text command without any pre-configured scope.","commands":{"allow":[],"deny":["set_text"]}},"deny-text":{"identifier":"deny-text","description":"Denies the text command without any pre-configured scope.","commands":{"allow":[],"deny":["text"]}}},"permission_sets":{},"global_scope_schema":null},"core:path":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-resolve-directory","allow-resolve","allow-normalize","allow-join","allow-dirname","allow-extname","allow-basename","allow-is-absolute"]},"permissions":{"allow-basename":{"identifier":"allow-basename","description":"Enables the basename command without any pre-configured scope.","commands":{"allow":["basename"],"deny":[]}},"allow-dirname":{"identifier":"allow-dirname","description":"Enables the dirname command without any pre-configured scope.","commands":{"allow":["dirname"],"deny":[]}},"allow-extname":{"identifier":"allow-extname","description":"Enables the extname command without any pre-configured scope.","commands":{"allow":["extname"],"deny":[]}},"allow-is-absolute":{"identifier":"allow-is-absolute","description":"Enables the is_absolute command without any pre-configured scope.","commands":{"allow":["is_absolute"],"deny":[]}},"allow-join":{"identifier":"allow-join","description":"Enables the join command without any pre-configured scope.","commands":{"allow":["join"],"deny":[]}},"allow-normalize":{"identifier":"allow-normalize","description":"Enables the normalize command without any pre-configured scope.","commands":{"allow":["normalize"],"deny":[]}},"allow-resolve":{"identifier":"allow-resolve","description":"Enables the resolve command without any pre-configured scope.","commands":{"allow":["resolve"],"deny":[]}},"allow-resolve-directory":{"identifier":"allow-resolve-directory","description":"Enables the resolve_directory command without any pre-configured scope.","commands":{"allow":["resolve_directory"],"deny":[]}},"deny-basename":{"identifier":"deny-basename","description":"Denies the basename command without any pre-configured scope.","commands":{"allow":[],"deny":["basename"]}},"deny-dirname":{"identifier":"deny-dirname","description":"Denies the dirname command without any pre-configured scope.","commands":{"allow":[],"deny":["dirname"]}},"deny-extname":{"identifier":"deny-extname","description":"Denies the extname command without any pre-configured scope.","commands":{"allow":[],"deny":["extname"]}},"deny-is-absolute":{"identifier":"deny-is-absolute","description":"Denies the is_absolute command without any pre-configured scope.","commands":{"allow":[],"deny":["is_absolute"]}},"deny-join":{"identifier":"deny-join","description":"Denies the join command without any pre-configured scope.","commands":{"allow":[],"deny":["join"]}},"deny-normalize":{"identifier":"deny-normalize","description":"Denies the normalize command without any pre-configured scope.","commands":{"allow":[],"deny":["normalize"]}},"deny-resolve":{"identifier":"deny-resolve","description":"Denies the resolve command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve"]}},"deny-resolve-directory":{"identifier":"deny-resolve-directory","description":"Denies the resolve_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve_directory"]}}},"permission_sets":{},"global_scope_schema":null},"core:resources":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-close"]},"permissions":{"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}}},"permission_sets":{},"global_scope_schema":null},"core:tray":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-get-by-id","allow-remove-by-id","allow-set-icon","allow-set-menu","allow-set-tooltip","allow-set-title","allow-set-visible","allow-set-temp-dir-path","allow-set-icon-as-template","allow-set-icon-with-as-template","allow-set-show-menu-on-left-click"]},"permissions":{"allow-get-by-id":{"identifier":"allow-get-by-id","description":"Enables the get_by_id command without any pre-configured scope.","commands":{"allow":["get_by_id"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-remove-by-id":{"identifier":"allow-remove-by-id","description":"Enables the remove_by_id command without any pre-configured scope.","commands":{"allow":["remove_by_id"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-icon-as-template":{"identifier":"allow-set-icon-as-template","description":"Enables the set_icon_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_as_template"],"deny":[]}},"allow-set-icon-with-as-template":{"identifier":"allow-set-icon-with-as-template","description":"Enables the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_with_as_template"],"deny":[]}},"allow-set-menu":{"identifier":"allow-set-menu","description":"Enables the set_menu command without any pre-configured scope.","commands":{"allow":["set_menu"],"deny":[]}},"allow-set-show-menu-on-left-click":{"identifier":"allow-set-show-menu-on-left-click","description":"Enables the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":["set_show_menu_on_left_click"],"deny":[]}},"allow-set-temp-dir-path":{"identifier":"allow-set-temp-dir-path","description":"Enables the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":["set_temp_dir_path"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-tooltip":{"identifier":"allow-set-tooltip","description":"Enables the set_tooltip command without any pre-configured scope.","commands":{"allow":["set_tooltip"],"deny":[]}},"allow-set-visible":{"identifier":"allow-set-visible","description":"Enables the set_visible command without any pre-configured scope.","commands":{"allow":["set_visible"],"deny":[]}},"deny-get-by-id":{"identifier":"deny-get-by-id","description":"Denies the get_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["get_by_id"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-remove-by-id":{"identifier":"deny-remove-by-id","description":"Denies the remove_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_by_id"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-icon-as-template":{"identifier":"deny-set-icon-as-template","description":"Denies the set_icon_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_as_template"]}},"deny-set-icon-with-as-template":{"identifier":"deny-set-icon-with-as-template","description":"Denies the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_with_as_template"]}},"deny-set-menu":{"identifier":"deny-set-menu","description":"Denies the set_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_menu"]}},"deny-set-show-menu-on-left-click":{"identifier":"deny-set-show-menu-on-left-click","description":"Denies the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":[],"deny":["set_show_menu_on_left_click"]}},"deny-set-temp-dir-path":{"identifier":"deny-set-temp-dir-path","description":"Denies the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":[],"deny":["set_temp_dir_path"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-tooltip":{"identifier":"deny-set-tooltip","description":"Denies the set_tooltip command without any pre-configured scope.","commands":{"allow":[],"deny":["set_tooltip"]}},"deny-set-visible":{"identifier":"deny-set-visible","description":"Denies the set_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible"]}}},"permission_sets":{},"global_scope_schema":null},"core:webview":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-webviews","allow-webview-position","allow-webview-size","allow-internal-toggle-devtools"]},"permissions":{"allow-clear-all-browsing-data":{"identifier":"allow-clear-all-browsing-data","description":"Enables the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":["clear_all_browsing_data"],"deny":[]}},"allow-create-webview":{"identifier":"allow-create-webview","description":"Enables the create_webview command without any pre-configured scope.","commands":{"allow":["create_webview"],"deny":[]}},"allow-create-webview-window":{"identifier":"allow-create-webview-window","description":"Enables the create_webview_window command without any pre-configured scope.","commands":{"allow":["create_webview_window"],"deny":[]}},"allow-get-all-webviews":{"identifier":"allow-get-all-webviews","description":"Enables the get_all_webviews command without any pre-configured scope.","commands":{"allow":["get_all_webviews"],"deny":[]}},"allow-internal-toggle-devtools":{"identifier":"allow-internal-toggle-devtools","description":"Enables the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":["internal_toggle_devtools"],"deny":[]}},"allow-print":{"identifier":"allow-print","description":"Enables the print command without any pre-configured scope.","commands":{"allow":["print"],"deny":[]}},"allow-reparent":{"identifier":"allow-reparent","description":"Enables the reparent command without any pre-configured scope.","commands":{"allow":["reparent"],"deny":[]}},"allow-set-webview-auto-resize":{"identifier":"allow-set-webview-auto-resize","description":"Enables the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":["set_webview_auto_resize"],"deny":[]}},"allow-set-webview-background-color":{"identifier":"allow-set-webview-background-color","description":"Enables the set_webview_background_color command without any pre-configured scope.","commands":{"allow":["set_webview_background_color"],"deny":[]}},"allow-set-webview-focus":{"identifier":"allow-set-webview-focus","description":"Enables the set_webview_focus command without any pre-configured scope.","commands":{"allow":["set_webview_focus"],"deny":[]}},"allow-set-webview-position":{"identifier":"allow-set-webview-position","description":"Enables the set_webview_position command without any pre-configured scope.","commands":{"allow":["set_webview_position"],"deny":[]}},"allow-set-webview-size":{"identifier":"allow-set-webview-size","description":"Enables the set_webview_size command without any pre-configured scope.","commands":{"allow":["set_webview_size"],"deny":[]}},"allow-set-webview-zoom":{"identifier":"allow-set-webview-zoom","description":"Enables the set_webview_zoom command without any pre-configured scope.","commands":{"allow":["set_webview_zoom"],"deny":[]}},"allow-webview-close":{"identifier":"allow-webview-close","description":"Enables the webview_close command without any pre-configured scope.","commands":{"allow":["webview_close"],"deny":[]}},"allow-webview-hide":{"identifier":"allow-webview-hide","description":"Enables the webview_hide command without any pre-configured scope.","commands":{"allow":["webview_hide"],"deny":[]}},"allow-webview-position":{"identifier":"allow-webview-position","description":"Enables the webview_position command without any pre-configured scope.","commands":{"allow":["webview_position"],"deny":[]}},"allow-webview-show":{"identifier":"allow-webview-show","description":"Enables the webview_show command without any pre-configured scope.","commands":{"allow":["webview_show"],"deny":[]}},"allow-webview-size":{"identifier":"allow-webview-size","description":"Enables the webview_size command without any pre-configured scope.","commands":{"allow":["webview_size"],"deny":[]}},"deny-clear-all-browsing-data":{"identifier":"deny-clear-all-browsing-data","description":"Denies the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_all_browsing_data"]}},"deny-create-webview":{"identifier":"deny-create-webview","description":"Denies the create_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview"]}},"deny-create-webview-window":{"identifier":"deny-create-webview-window","description":"Denies the create_webview_window command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview_window"]}},"deny-get-all-webviews":{"identifier":"deny-get-all-webviews","description":"Denies the get_all_webviews command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_webviews"]}},"deny-internal-toggle-devtools":{"identifier":"deny-internal-toggle-devtools","description":"Denies the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_devtools"]}},"deny-print":{"identifier":"deny-print","description":"Denies the print command without any pre-configured scope.","commands":{"allow":[],"deny":["print"]}},"deny-reparent":{"identifier":"deny-reparent","description":"Denies the reparent command without any pre-configured scope.","commands":{"allow":[],"deny":["reparent"]}},"deny-set-webview-auto-resize":{"identifier":"deny-set-webview-auto-resize","description":"Denies the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_auto_resize"]}},"deny-set-webview-background-color":{"identifier":"deny-set-webview-background-color","description":"Denies the set_webview_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_background_color"]}},"deny-set-webview-focus":{"identifier":"deny-set-webview-focus","description":"Denies the set_webview_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_focus"]}},"deny-set-webview-position":{"identifier":"deny-set-webview-position","description":"Denies the set_webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_position"]}},"deny-set-webview-size":{"identifier":"deny-set-webview-size","description":"Denies the set_webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_size"]}},"deny-set-webview-zoom":{"identifier":"deny-set-webview-zoom","description":"Denies the set_webview_zoom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_zoom"]}},"deny-webview-close":{"identifier":"deny-webview-close","description":"Denies the webview_close command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_close"]}},"deny-webview-hide":{"identifier":"deny-webview-hide","description":"Denies the webview_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_hide"]}},"deny-webview-position":{"identifier":"deny-webview-position","description":"Denies the webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_position"]}},"deny-webview-show":{"identifier":"deny-webview-show","description":"Denies the webview_show command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_show"]}},"deny-webview-size":{"identifier":"deny-webview-size","description":"Denies the webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_size"]}}},"permission_sets":{},"global_scope_schema":null},"core:window":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-windows","allow-scale-factor","allow-inner-position","allow-outer-position","allow-inner-size","allow-outer-size","allow-is-fullscreen","allow-is-minimized","allow-is-maximized","allow-is-focused","allow-is-decorated","allow-is-resizable","allow-is-maximizable","allow-is-minimizable","allow-is-closable","allow-is-visible","allow-is-enabled","allow-title","allow-current-monitor","allow-primary-monitor","allow-monitor-from-point","allow-available-monitors","allow-cursor-position","allow-theme","allow-is-always-on-top","allow-activity-name","allow-scene-identifier","allow-internal-toggle-maximize"]},"permissions":{"allow-activity-name":{"identifier":"allow-activity-name","description":"Enables the activity_name command without any pre-configured scope.","commands":{"allow":["activity_name"],"deny":[]}},"allow-available-monitors":{"identifier":"allow-available-monitors","description":"Enables the available_monitors command without any pre-configured scope.","commands":{"allow":["available_monitors"],"deny":[]}},"allow-center":{"identifier":"allow-center","description":"Enables the center command without any pre-configured scope.","commands":{"allow":["center"],"deny":[]}},"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"allow-create":{"identifier":"allow-create","description":"Enables the create command without any pre-configured scope.","commands":{"allow":["create"],"deny":[]}},"allow-current-monitor":{"identifier":"allow-current-monitor","description":"Enables the current_monitor command without any pre-configured scope.","commands":{"allow":["current_monitor"],"deny":[]}},"allow-cursor-position":{"identifier":"allow-cursor-position","description":"Enables the cursor_position command without any pre-configured scope.","commands":{"allow":["cursor_position"],"deny":[]}},"allow-destroy":{"identifier":"allow-destroy","description":"Enables the destroy command without any pre-configured scope.","commands":{"allow":["destroy"],"deny":[]}},"allow-get-all-windows":{"identifier":"allow-get-all-windows","description":"Enables the get_all_windows command without any pre-configured scope.","commands":{"allow":["get_all_windows"],"deny":[]}},"allow-hide":{"identifier":"allow-hide","description":"Enables the hide command without any pre-configured scope.","commands":{"allow":["hide"],"deny":[]}},"allow-inner-position":{"identifier":"allow-inner-position","description":"Enables the inner_position command without any pre-configured scope.","commands":{"allow":["inner_position"],"deny":[]}},"allow-inner-size":{"identifier":"allow-inner-size","description":"Enables the inner_size command without any pre-configured scope.","commands":{"allow":["inner_size"],"deny":[]}},"allow-internal-toggle-maximize":{"identifier":"allow-internal-toggle-maximize","description":"Enables the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":["internal_toggle_maximize"],"deny":[]}},"allow-is-always-on-top":{"identifier":"allow-is-always-on-top","description":"Enables the is_always_on_top command without any pre-configured scope.","commands":{"allow":["is_always_on_top"],"deny":[]}},"allow-is-closable":{"identifier":"allow-is-closable","description":"Enables the is_closable command without any pre-configured scope.","commands":{"allow":["is_closable"],"deny":[]}},"allow-is-decorated":{"identifier":"allow-is-decorated","description":"Enables the is_decorated command without any pre-configured scope.","commands":{"allow":["is_decorated"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-is-focused":{"identifier":"allow-is-focused","description":"Enables the is_focused command without any pre-configured scope.","commands":{"allow":["is_focused"],"deny":[]}},"allow-is-fullscreen":{"identifier":"allow-is-fullscreen","description":"Enables the is_fullscreen command without any pre-configured scope.","commands":{"allow":["is_fullscreen"],"deny":[]}},"allow-is-maximizable":{"identifier":"allow-is-maximizable","description":"Enables the is_maximizable command without any pre-configured scope.","commands":{"allow":["is_maximizable"],"deny":[]}},"allow-is-maximized":{"identifier":"allow-is-maximized","description":"Enables the is_maximized command without any pre-configured scope.","commands":{"allow":["is_maximized"],"deny":[]}},"allow-is-minimizable":{"identifier":"allow-is-minimizable","description":"Enables the is_minimizable command without any pre-configured scope.","commands":{"allow":["is_minimizable"],"deny":[]}},"allow-is-minimized":{"identifier":"allow-is-minimized","description":"Enables the is_minimized command without any pre-configured scope.","commands":{"allow":["is_minimized"],"deny":[]}},"allow-is-resizable":{"identifier":"allow-is-resizable","description":"Enables the is_resizable command without any pre-configured scope.","commands":{"allow":["is_resizable"],"deny":[]}},"allow-is-visible":{"identifier":"allow-is-visible","description":"Enables the is_visible command without any pre-configured scope.","commands":{"allow":["is_visible"],"deny":[]}},"allow-maximize":{"identifier":"allow-maximize","description":"Enables the maximize command without any pre-configured scope.","commands":{"allow":["maximize"],"deny":[]}},"allow-minimize":{"identifier":"allow-minimize","description":"Enables the minimize command without any pre-configured scope.","commands":{"allow":["minimize"],"deny":[]}},"allow-monitor-from-point":{"identifier":"allow-monitor-from-point","description":"Enables the monitor_from_point command without any pre-configured scope.","commands":{"allow":["monitor_from_point"],"deny":[]}},"allow-outer-position":{"identifier":"allow-outer-position","description":"Enables the outer_position command without any pre-configured scope.","commands":{"allow":["outer_position"],"deny":[]}},"allow-outer-size":{"identifier":"allow-outer-size","description":"Enables the outer_size command without any pre-configured scope.","commands":{"allow":["outer_size"],"deny":[]}},"allow-primary-monitor":{"identifier":"allow-primary-monitor","description":"Enables the primary_monitor command without any pre-configured scope.","commands":{"allow":["primary_monitor"],"deny":[]}},"allow-request-user-attention":{"identifier":"allow-request-user-attention","description":"Enables the request_user_attention command without any pre-configured scope.","commands":{"allow":["request_user_attention"],"deny":[]}},"allow-scale-factor":{"identifier":"allow-scale-factor","description":"Enables the scale_factor command without any pre-configured scope.","commands":{"allow":["scale_factor"],"deny":[]}},"allow-scene-identifier":{"identifier":"allow-scene-identifier","description":"Enables the scene_identifier command without any pre-configured scope.","commands":{"allow":["scene_identifier"],"deny":[]}},"allow-set-always-on-bottom":{"identifier":"allow-set-always-on-bottom","description":"Enables the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":["set_always_on_bottom"],"deny":[]}},"allow-set-always-on-top":{"identifier":"allow-set-always-on-top","description":"Enables the set_always_on_top command without any pre-configured scope.","commands":{"allow":["set_always_on_top"],"deny":[]}},"allow-set-background-color":{"identifier":"allow-set-background-color","description":"Enables the set_background_color command without any pre-configured scope.","commands":{"allow":["set_background_color"],"deny":[]}},"allow-set-badge-count":{"identifier":"allow-set-badge-count","description":"Enables the set_badge_count command without any pre-configured scope.","commands":{"allow":["set_badge_count"],"deny":[]}},"allow-set-badge-label":{"identifier":"allow-set-badge-label","description":"Enables the set_badge_label command without any pre-configured scope.","commands":{"allow":["set_badge_label"],"deny":[]}},"allow-set-closable":{"identifier":"allow-set-closable","description":"Enables the set_closable command without any pre-configured scope.","commands":{"allow":["set_closable"],"deny":[]}},"allow-set-content-protected":{"identifier":"allow-set-content-protected","description":"Enables the set_content_protected command without any pre-configured scope.","commands":{"allow":["set_content_protected"],"deny":[]}},"allow-set-cursor-grab":{"identifier":"allow-set-cursor-grab","description":"Enables the set_cursor_grab command without any pre-configured scope.","commands":{"allow":["set_cursor_grab"],"deny":[]}},"allow-set-cursor-icon":{"identifier":"allow-set-cursor-icon","description":"Enables the set_cursor_icon command without any pre-configured scope.","commands":{"allow":["set_cursor_icon"],"deny":[]}},"allow-set-cursor-position":{"identifier":"allow-set-cursor-position","description":"Enables the set_cursor_position command without any pre-configured scope.","commands":{"allow":["set_cursor_position"],"deny":[]}},"allow-set-cursor-visible":{"identifier":"allow-set-cursor-visible","description":"Enables the set_cursor_visible command without any pre-configured scope.","commands":{"allow":["set_cursor_visible"],"deny":[]}},"allow-set-decorations":{"identifier":"allow-set-decorations","description":"Enables the set_decorations command without any pre-configured scope.","commands":{"allow":["set_decorations"],"deny":[]}},"allow-set-effects":{"identifier":"allow-set-effects","description":"Enables the set_effects command without any pre-configured scope.","commands":{"allow":["set_effects"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-focus":{"identifier":"allow-set-focus","description":"Enables the set_focus command without any pre-configured scope.","commands":{"allow":["set_focus"],"deny":[]}},"allow-set-focusable":{"identifier":"allow-set-focusable","description":"Enables the set_focusable command without any pre-configured scope.","commands":{"allow":["set_focusable"],"deny":[]}},"allow-set-fullscreen":{"identifier":"allow-set-fullscreen","description":"Enables the set_fullscreen command without any pre-configured scope.","commands":{"allow":["set_fullscreen"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-ignore-cursor-events":{"identifier":"allow-set-ignore-cursor-events","description":"Enables the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":["set_ignore_cursor_events"],"deny":[]}},"allow-set-max-size":{"identifier":"allow-set-max-size","description":"Enables the set_max_size command without any pre-configured scope.","commands":{"allow":["set_max_size"],"deny":[]}},"allow-set-maximizable":{"identifier":"allow-set-maximizable","description":"Enables the set_maximizable command without any pre-configured scope.","commands":{"allow":["set_maximizable"],"deny":[]}},"allow-set-min-size":{"identifier":"allow-set-min-size","description":"Enables the set_min_size command without any pre-configured scope.","commands":{"allow":["set_min_size"],"deny":[]}},"allow-set-minimizable":{"identifier":"allow-set-minimizable","description":"Enables the set_minimizable command without any pre-configured scope.","commands":{"allow":["set_minimizable"],"deny":[]}},"allow-set-overlay-icon":{"identifier":"allow-set-overlay-icon","description":"Enables the set_overlay_icon command without any pre-configured scope.","commands":{"allow":["set_overlay_icon"],"deny":[]}},"allow-set-position":{"identifier":"allow-set-position","description":"Enables the set_position command without any pre-configured scope.","commands":{"allow":["set_position"],"deny":[]}},"allow-set-progress-bar":{"identifier":"allow-set-progress-bar","description":"Enables the set_progress_bar command without any pre-configured scope.","commands":{"allow":["set_progress_bar"],"deny":[]}},"allow-set-resizable":{"identifier":"allow-set-resizable","description":"Enables the set_resizable command without any pre-configured scope.","commands":{"allow":["set_resizable"],"deny":[]}},"allow-set-shadow":{"identifier":"allow-set-shadow","description":"Enables the set_shadow command without any pre-configured scope.","commands":{"allow":["set_shadow"],"deny":[]}},"allow-set-simple-fullscreen":{"identifier":"allow-set-simple-fullscreen","description":"Enables the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":["set_simple_fullscreen"],"deny":[]}},"allow-set-size":{"identifier":"allow-set-size","description":"Enables the set_size command without any pre-configured scope.","commands":{"allow":["set_size"],"deny":[]}},"allow-set-size-constraints":{"identifier":"allow-set-size-constraints","description":"Enables the set_size_constraints command without any pre-configured scope.","commands":{"allow":["set_size_constraints"],"deny":[]}},"allow-set-skip-taskbar":{"identifier":"allow-set-skip-taskbar","description":"Enables the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":["set_skip_taskbar"],"deny":[]}},"allow-set-theme":{"identifier":"allow-set-theme","description":"Enables the set_theme command without any pre-configured scope.","commands":{"allow":["set_theme"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-title-bar-style":{"identifier":"allow-set-title-bar-style","description":"Enables the set_title_bar_style command without any pre-configured scope.","commands":{"allow":["set_title_bar_style"],"deny":[]}},"allow-set-visible-on-all-workspaces":{"identifier":"allow-set-visible-on-all-workspaces","description":"Enables the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":["set_visible_on_all_workspaces"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"allow-start-dragging":{"identifier":"allow-start-dragging","description":"Enables the start_dragging command without any pre-configured scope.","commands":{"allow":["start_dragging"],"deny":[]}},"allow-start-resize-dragging":{"identifier":"allow-start-resize-dragging","description":"Enables the start_resize_dragging command without any pre-configured scope.","commands":{"allow":["start_resize_dragging"],"deny":[]}},"allow-theme":{"identifier":"allow-theme","description":"Enables the theme command without any pre-configured scope.","commands":{"allow":["theme"],"deny":[]}},"allow-title":{"identifier":"allow-title","description":"Enables the title command without any pre-configured scope.","commands":{"allow":["title"],"deny":[]}},"allow-toggle-maximize":{"identifier":"allow-toggle-maximize","description":"Enables the toggle_maximize command without any pre-configured scope.","commands":{"allow":["toggle_maximize"],"deny":[]}},"allow-unmaximize":{"identifier":"allow-unmaximize","description":"Enables the unmaximize command without any pre-configured scope.","commands":{"allow":["unmaximize"],"deny":[]}},"allow-unminimize":{"identifier":"allow-unminimize","description":"Enables the unminimize command without any pre-configured scope.","commands":{"allow":["unminimize"],"deny":[]}},"deny-activity-name":{"identifier":"deny-activity-name","description":"Denies the activity_name command without any pre-configured scope.","commands":{"allow":[],"deny":["activity_name"]}},"deny-available-monitors":{"identifier":"deny-available-monitors","description":"Denies the available_monitors command without any pre-configured scope.","commands":{"allow":[],"deny":["available_monitors"]}},"deny-center":{"identifier":"deny-center","description":"Denies the center command without any pre-configured scope.","commands":{"allow":[],"deny":["center"]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}},"deny-create":{"identifier":"deny-create","description":"Denies the create command without any pre-configured scope.","commands":{"allow":[],"deny":["create"]}},"deny-current-monitor":{"identifier":"deny-current-monitor","description":"Denies the current_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["current_monitor"]}},"deny-cursor-position":{"identifier":"deny-cursor-position","description":"Denies the cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["cursor_position"]}},"deny-destroy":{"identifier":"deny-destroy","description":"Denies the destroy command without any pre-configured scope.","commands":{"allow":[],"deny":["destroy"]}},"deny-get-all-windows":{"identifier":"deny-get-all-windows","description":"Denies the get_all_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_windows"]}},"deny-hide":{"identifier":"deny-hide","description":"Denies the hide command without any pre-configured scope.","commands":{"allow":[],"deny":["hide"]}},"deny-inner-position":{"identifier":"deny-inner-position","description":"Denies the inner_position command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_position"]}},"deny-inner-size":{"identifier":"deny-inner-size","description":"Denies the inner_size command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_size"]}},"deny-internal-toggle-maximize":{"identifier":"deny-internal-toggle-maximize","description":"Denies the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_maximize"]}},"deny-is-always-on-top":{"identifier":"deny-is-always-on-top","description":"Denies the is_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["is_always_on_top"]}},"deny-is-closable":{"identifier":"deny-is-closable","description":"Denies the is_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_closable"]}},"deny-is-decorated":{"identifier":"deny-is-decorated","description":"Denies the is_decorated command without any pre-configured scope.","commands":{"allow":[],"deny":["is_decorated"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-is-focused":{"identifier":"deny-is-focused","description":"Denies the is_focused command without any pre-configured scope.","commands":{"allow":[],"deny":["is_focused"]}},"deny-is-fullscreen":{"identifier":"deny-is-fullscreen","description":"Denies the is_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["is_fullscreen"]}},"deny-is-maximizable":{"identifier":"deny-is-maximizable","description":"Denies the is_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximizable"]}},"deny-is-maximized":{"identifier":"deny-is-maximized","description":"Denies the is_maximized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximized"]}},"deny-is-minimizable":{"identifier":"deny-is-minimizable","description":"Denies the is_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimizable"]}},"deny-is-minimized":{"identifier":"deny-is-minimized","description":"Denies the is_minimized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimized"]}},"deny-is-resizable":{"identifier":"deny-is-resizable","description":"Denies the is_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_resizable"]}},"deny-is-visible":{"identifier":"deny-is-visible","description":"Denies the is_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["is_visible"]}},"deny-maximize":{"identifier":"deny-maximize","description":"Denies the maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["maximize"]}},"deny-minimize":{"identifier":"deny-minimize","description":"Denies the minimize command without any pre-configured scope.","commands":{"allow":[],"deny":["minimize"]}},"deny-monitor-from-point":{"identifier":"deny-monitor-from-point","description":"Denies the monitor_from_point command without any pre-configured scope.","commands":{"allow":[],"deny":["monitor_from_point"]}},"deny-outer-position":{"identifier":"deny-outer-position","description":"Denies the outer_position command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_position"]}},"deny-outer-size":{"identifier":"deny-outer-size","description":"Denies the outer_size command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_size"]}},"deny-primary-monitor":{"identifier":"deny-primary-monitor","description":"Denies the primary_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["primary_monitor"]}},"deny-request-user-attention":{"identifier":"deny-request-user-attention","description":"Denies the request_user_attention command without any pre-configured scope.","commands":{"allow":[],"deny":["request_user_attention"]}},"deny-scale-factor":{"identifier":"deny-scale-factor","description":"Denies the scale_factor command without any pre-configured scope.","commands":{"allow":[],"deny":["scale_factor"]}},"deny-scene-identifier":{"identifier":"deny-scene-identifier","description":"Denies the scene_identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["scene_identifier"]}},"deny-set-always-on-bottom":{"identifier":"deny-set-always-on-bottom","description":"Denies the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_bottom"]}},"deny-set-always-on-top":{"identifier":"deny-set-always-on-top","description":"Denies the set_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_top"]}},"deny-set-background-color":{"identifier":"deny-set-background-color","description":"Denies the set_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_background_color"]}},"deny-set-badge-count":{"identifier":"deny-set-badge-count","description":"Denies the set_badge_count command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_count"]}},"deny-set-badge-label":{"identifier":"deny-set-badge-label","description":"Denies the set_badge_label command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_label"]}},"deny-set-closable":{"identifier":"deny-set-closable","description":"Denies the set_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_closable"]}},"deny-set-content-protected":{"identifier":"deny-set-content-protected","description":"Denies the set_content_protected command without any pre-configured scope.","commands":{"allow":[],"deny":["set_content_protected"]}},"deny-set-cursor-grab":{"identifier":"deny-set-cursor-grab","description":"Denies the set_cursor_grab command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_grab"]}},"deny-set-cursor-icon":{"identifier":"deny-set-cursor-icon","description":"Denies the set_cursor_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_icon"]}},"deny-set-cursor-position":{"identifier":"deny-set-cursor-position","description":"Denies the set_cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_position"]}},"deny-set-cursor-visible":{"identifier":"deny-set-cursor-visible","description":"Denies the set_cursor_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_visible"]}},"deny-set-decorations":{"identifier":"deny-set-decorations","description":"Denies the set_decorations command without any pre-configured scope.","commands":{"allow":[],"deny":["set_decorations"]}},"deny-set-effects":{"identifier":"deny-set-effects","description":"Denies the set_effects command without any pre-configured scope.","commands":{"allow":[],"deny":["set_effects"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-focus":{"identifier":"deny-set-focus","description":"Denies the set_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focus"]}},"deny-set-focusable":{"identifier":"deny-set-focusable","description":"Denies the set_focusable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focusable"]}},"deny-set-fullscreen":{"identifier":"deny-set-fullscreen","description":"Denies the set_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-ignore-cursor-events":{"identifier":"deny-set-ignore-cursor-events","description":"Denies the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":[],"deny":["set_ignore_cursor_events"]}},"deny-set-max-size":{"identifier":"deny-set-max-size","description":"Denies the set_max_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_max_size"]}},"deny-set-maximizable":{"identifier":"deny-set-maximizable","description":"Denies the set_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_maximizable"]}},"deny-set-min-size":{"identifier":"deny-set-min-size","description":"Denies the set_min_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_min_size"]}},"deny-set-minimizable":{"identifier":"deny-set-minimizable","description":"Denies the set_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_minimizable"]}},"deny-set-overlay-icon":{"identifier":"deny-set-overlay-icon","description":"Denies the set_overlay_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_overlay_icon"]}},"deny-set-position":{"identifier":"deny-set-position","description":"Denies the set_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_position"]}},"deny-set-progress-bar":{"identifier":"deny-set-progress-bar","description":"Denies the set_progress_bar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_progress_bar"]}},"deny-set-resizable":{"identifier":"deny-set-resizable","description":"Denies the set_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_resizable"]}},"deny-set-shadow":{"identifier":"deny-set-shadow","description":"Denies the set_shadow command without any pre-configured scope.","commands":{"allow":[],"deny":["set_shadow"]}},"deny-set-simple-fullscreen":{"identifier":"deny-set-simple-fullscreen","description":"Denies the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_simple_fullscreen"]}},"deny-set-size":{"identifier":"deny-set-size","description":"Denies the set_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size"]}},"deny-set-size-constraints":{"identifier":"deny-set-size-constraints","description":"Denies the set_size_constraints command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size_constraints"]}},"deny-set-skip-taskbar":{"identifier":"deny-set-skip-taskbar","description":"Denies the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_skip_taskbar"]}},"deny-set-theme":{"identifier":"deny-set-theme","description":"Denies the set_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_theme"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-title-bar-style":{"identifier":"deny-set-title-bar-style","description":"Denies the set_title_bar_style command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title_bar_style"]}},"deny-set-visible-on-all-workspaces":{"identifier":"deny-set-visible-on-all-workspaces","description":"Denies the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible_on_all_workspaces"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}},"deny-start-dragging":{"identifier":"deny-start-dragging","description":"Denies the start_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_dragging"]}},"deny-start-resize-dragging":{"identifier":"deny-start-resize-dragging","description":"Denies the start_resize_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_resize_dragging"]}},"deny-theme":{"identifier":"deny-theme","description":"Denies the theme command without any pre-configured scope.","commands":{"allow":[],"deny":["theme"]}},"deny-title":{"identifier":"deny-title","description":"Denies the title command without any pre-configured scope.","commands":{"allow":[],"deny":["title"]}},"deny-toggle-maximize":{"identifier":"deny-toggle-maximize","description":"Denies the toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["toggle_maximize"]}},"deny-unmaximize":{"identifier":"deny-unmaximize","description":"Denies the unmaximize command without any pre-configured scope.","commands":{"allow":[],"deny":["unmaximize"]}},"deny-unminimize":{"identifier":"deny-unminimize","description":"Denies the unminimize command without any pre-configured scope.","commands":{"allow":[],"deny":["unminimize"]}}},"permission_sets":{},"global_scope_schema":null},"dialog":{"default_permission":{"identifier":"default","description":"This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n","permissions":["allow-message","allow-save","allow-open"]},"permissions":{"allow-ask":{"identifier":"allow-ask","description":"Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)","commands":{"allow":["message"],"deny":[]}},"allow-confirm":{"identifier":"allow-confirm","description":"Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)","commands":{"allow":["message"],"deny":[]}},"allow-message":{"identifier":"allow-message","description":"Enables the message command without any pre-configured scope.","commands":{"allow":["message"],"deny":[]}},"allow-open":{"identifier":"allow-open","description":"Enables the open command without any pre-configured scope.","commands":{"allow":["open"],"deny":[]}},"allow-save":{"identifier":"allow-save","description":"Enables the save command without any pre-configured scope.","commands":{"allow":["save"],"deny":[]}},"deny-ask":{"identifier":"deny-ask","description":"Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)","commands":{"allow":[],"deny":["message"]}},"deny-confirm":{"identifier":"deny-confirm","description":"Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)","commands":{"allow":[],"deny":["message"]}},"deny-message":{"identifier":"deny-message","description":"Denies the message command without any pre-configured scope.","commands":{"allow":[],"deny":["message"]}},"deny-open":{"identifier":"deny-open","description":"Denies the open command without any pre-configured scope.","commands":{"allow":[],"deny":["open"]}},"deny-save":{"identifier":"deny-save","description":"Denies the save command without any pre-configured scope.","commands":{"allow":[],"deny":["save"]}}},"permission_sets":{},"global_scope_schema":null},"notification":{"default_permission":{"identifier":"default","description":"This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n","permissions":["allow-is-permission-granted","allow-request-permission","allow-notify","allow-register-action-types","allow-register-listener","allow-cancel","allow-get-pending","allow-remove-active","allow-get-active","allow-check-permissions","allow-show","allow-batch","allow-list-channels","allow-delete-channel","allow-create-channel","allow-permission-state"]},"permissions":{"allow-batch":{"identifier":"allow-batch","description":"Enables the batch command without any pre-configured scope.","commands":{"allow":["batch"],"deny":[]}},"allow-cancel":{"identifier":"allow-cancel","description":"Enables the cancel command without any pre-configured scope.","commands":{"allow":["cancel"],"deny":[]}},"allow-check-permissions":{"identifier":"allow-check-permissions","description":"Enables the check_permissions command without any pre-configured scope.","commands":{"allow":["check_permissions"],"deny":[]}},"allow-create-channel":{"identifier":"allow-create-channel","description":"Enables the create_channel command without any pre-configured scope.","commands":{"allow":["create_channel"],"deny":[]}},"allow-delete-channel":{"identifier":"allow-delete-channel","description":"Enables the delete_channel command without any pre-configured scope.","commands":{"allow":["delete_channel"],"deny":[]}},"allow-get-active":{"identifier":"allow-get-active","description":"Enables the get_active command without any pre-configured scope.","commands":{"allow":["get_active"],"deny":[]}},"allow-get-pending":{"identifier":"allow-get-pending","description":"Enables the get_pending command without any pre-configured scope.","commands":{"allow":["get_pending"],"deny":[]}},"allow-is-permission-granted":{"identifier":"allow-is-permission-granted","description":"Enables the is_permission_granted command without any pre-configured scope.","commands":{"allow":["is_permission_granted"],"deny":[]}},"allow-list-channels":{"identifier":"allow-list-channels","description":"Enables the list_channels command without any pre-configured scope.","commands":{"allow":["list_channels"],"deny":[]}},"allow-notify":{"identifier":"allow-notify","description":"Enables the notify command without any pre-configured scope.","commands":{"allow":["notify"],"deny":[]}},"allow-permission-state":{"identifier":"allow-permission-state","description":"Enables the permission_state command without any pre-configured scope.","commands":{"allow":["permission_state"],"deny":[]}},"allow-register-action-types":{"identifier":"allow-register-action-types","description":"Enables the register_action_types command without any pre-configured scope.","commands":{"allow":["register_action_types"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-active":{"identifier":"allow-remove-active","description":"Enables the remove_active command without any pre-configured scope.","commands":{"allow":["remove_active"],"deny":[]}},"allow-request-permission":{"identifier":"allow-request-permission","description":"Enables the request_permission command without any pre-configured scope.","commands":{"allow":["request_permission"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"deny-batch":{"identifier":"deny-batch","description":"Denies the batch command without any pre-configured scope.","commands":{"allow":[],"deny":["batch"]}},"deny-cancel":{"identifier":"deny-cancel","description":"Denies the cancel command without any pre-configured scope.","commands":{"allow":[],"deny":["cancel"]}},"deny-check-permissions":{"identifier":"deny-check-permissions","description":"Denies the check_permissions command without any pre-configured scope.","commands":{"allow":[],"deny":["check_permissions"]}},"deny-create-channel":{"identifier":"deny-create-channel","description":"Denies the create_channel command without any pre-configured scope.","commands":{"allow":[],"deny":["create_channel"]}},"deny-delete-channel":{"identifier":"deny-delete-channel","description":"Denies the delete_channel command without any pre-configured scope.","commands":{"allow":[],"deny":["delete_channel"]}},"deny-get-active":{"identifier":"deny-get-active","description":"Denies the get_active command without any pre-configured scope.","commands":{"allow":[],"deny":["get_active"]}},"deny-get-pending":{"identifier":"deny-get-pending","description":"Denies the get_pending command without any pre-configured scope.","commands":{"allow":[],"deny":["get_pending"]}},"deny-is-permission-granted":{"identifier":"deny-is-permission-granted","description":"Denies the is_permission_granted command without any pre-configured scope.","commands":{"allow":[],"deny":["is_permission_granted"]}},"deny-list-channels":{"identifier":"deny-list-channels","description":"Denies the list_channels command without any pre-configured scope.","commands":{"allow":[],"deny":["list_channels"]}},"deny-notify":{"identifier":"deny-notify","description":"Denies the notify command without any pre-configured scope.","commands":{"allow":[],"deny":["notify"]}},"deny-permission-state":{"identifier":"deny-permission-state","description":"Denies the permission_state command without any pre-configured scope.","commands":{"allow":[],"deny":["permission_state"]}},"deny-register-action-types":{"identifier":"deny-register-action-types","description":"Denies the register_action_types command without any pre-configured scope.","commands":{"allow":[],"deny":["register_action_types"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-active":{"identifier":"deny-remove-active","description":"Denies the remove_active command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_active"]}},"deny-request-permission":{"identifier":"deny-request-permission","description":"Denies the request_permission command without any pre-configured scope.","commands":{"allow":[],"deny":["request_permission"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}}},"permission_sets":{},"global_scope_schema":null},"opener":{"default_permission":{"identifier":"default","description":"This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer","permissions":["allow-open-url","allow-reveal-item-in-dir","allow-default-urls"]},"permissions":{"allow-default-urls":{"identifier":"allow-default-urls","description":"This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application.","commands":{"allow":[],"deny":[]},"scope":{"allow":[{"url":"mailto:*"},{"url":"tel:*"},{"url":"http://*"},{"url":"https://*"}]}},"allow-open-path":{"identifier":"allow-open-path","description":"Enables the open_path command without any pre-configured scope.","commands":{"allow":["open_path"],"deny":[]}},"allow-open-url":{"identifier":"allow-open-url","description":"Enables the open_url command without any pre-configured scope.","commands":{"allow":["open_url"],"deny":[]}},"allow-reveal-item-in-dir":{"identifier":"allow-reveal-item-in-dir","description":"Enables the reveal_item_in_dir command without any pre-configured scope.","commands":{"allow":["reveal_item_in_dir"],"deny":[]}},"deny-open-path":{"identifier":"deny-open-path","description":"Denies the open_path command without any pre-configured scope.","commands":{"allow":[],"deny":["open_path"]}},"deny-open-url":{"identifier":"deny-open-url","description":"Denies the open_url command without any pre-configured scope.","commands":{"allow":[],"deny":["open_url"]}},"deny-reveal-item-in-dir":{"identifier":"deny-reveal-item-in-dir","description":"Denies the reveal_item_in_dir command without any pre-configured scope.","commands":{"allow":[],"deny":["reveal_item_in_dir"]}}},"permission_sets":{},"global_scope_schema":{"$schema":"http://json-schema.org/draft-07/schema#","anyOf":[{"properties":{"app":{"allOf":[{"$ref":"#/definitions/Application"}],"description":"An application to open this url with, for example: firefox."},"url":{"description":"A URL that can be opened by the webview when using the Opener APIs.\n\nWildcards can be used following the UNIX glob pattern.\n\nExamples:\n\n- \"https://*\" : allows all HTTPS origin\n\n- \"https://*.github.com/tauri-apps/tauri\": allows any subdomain of \"github.com\" with the \"tauri-apps/api\" path\n\n- \"https://myapi.service.com/users/*\": allows access to any URLs that begins with \"https://myapi.service.com/users/\"","type":"string"}},"required":["url"],"type":"object"},{"properties":{"app":{"allOf":[{"$ref":"#/definitions/Application"}],"description":"An application to open this path with, for example: xdg-open."},"path":{"description":"A path that can be opened by the webview when using the Opener APIs.\n\nThe pattern can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$APP`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.","type":"string"}},"required":["path"],"type":"object"}],"definitions":{"Application":{"anyOf":[{"description":"Open in default application.","type":"null"},{"description":"If true, allow open with any application.","type":"boolean"},{"description":"Allow specific application to open with.","type":"string"}],"description":"Opener scope application."}},"description":"Opener scope entry.","title":"OpenerScopeEntry"}},"updater":{"default_permission":{"identifier":"default","description":"This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n","permissions":["allow-check","allow-download","allow-install","allow-download-and-install"]},"permissions":{"allow-check":{"identifier":"allow-check","description":"Enables the check command without any pre-configured scope.","commands":{"allow":["check"],"deny":[]}},"allow-download":{"identifier":"allow-download","description":"Enables the download command without any pre-configured scope.","commands":{"allow":["download"],"deny":[]}},"allow-download-and-install":{"identifier":"allow-download-and-install","description":"Enables the download_and_install command without any pre-configured scope.","commands":{"allow":["download_and_install"],"deny":[]}},"allow-install":{"identifier":"allow-install","description":"Enables the install command without any pre-configured scope.","commands":{"allow":["install"],"deny":[]}},"deny-check":{"identifier":"deny-check","description":"Denies the check command without any pre-configured scope.","commands":{"allow":[],"deny":["check"]}},"deny-download":{"identifier":"deny-download","description":"Denies the download command without any pre-configured scope.","commands":{"allow":[],"deny":["download"]}},"deny-download-and-install":{"identifier":"deny-download-and-install","description":"Denies the download_and_install command without any pre-configured scope.","commands":{"allow":[],"deny":["download_and_install"]}},"deny-install":{"identifier":"deny-install","description":"Denies the install command without any pre-configured scope.","commands":{"allow":[],"deny":["install"]}}},"permission_sets":{},"global_scope_schema":null}} \ No newline at end of file +{"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener","allow-supports-multiple-windows"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-exit":{"identifier":"allow-exit","description":"Enables the exit command without any pre-configured scope.","commands":{"allow":["exit"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-supports-multiple-windows":{"identifier":"allow-supports-multiple-windows","description":"Enables the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":["supports_multiple_windows"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-exit":{"identifier":"deny-exit","description":"Denies the exit command without any pre-configured scope.","commands":{"allow":[],"deny":["exit"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-data-store":{"identifier":"deny-remove-data-store","description":"Denies the remove_data_store command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_data_store"]}},"deny-remove-listener":{"identifier":"deny-remove-listener","description":"Denies the remove_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_listener"]}},"deny-set-app-theme":{"identifier":"deny-set-app-theme","description":"Denies the set_app_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_app_theme"]}},"deny-set-dock-visibility":{"identifier":"deny-set-dock-visibility","description":"Denies the set_dock_visibility command without any pre-configured scope.","commands":{"allow":[],"deny":["set_dock_visibility"]}},"deny-supports-multiple-windows":{"identifier":"deny-supports-multiple-windows","description":"Denies the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["supports_multiple_windows"]}},"deny-tauri-version":{"identifier":"deny-tauri-version","description":"Denies the tauri_version command without any pre-configured scope.","commands":{"allow":[],"deny":["tauri_version"]}},"deny-version":{"identifier":"deny-version","description":"Denies the version command without any pre-configured scope.","commands":{"allow":[],"deny":["version"]}}},"permission_sets":{},"global_scope_schema":null},"core:event":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-listen","allow-unlisten","allow-emit","allow-emit-to"]},"permissions":{"allow-emit":{"identifier":"allow-emit","description":"Enables the emit command without any pre-configured scope.","commands":{"allow":["emit"],"deny":[]}},"allow-emit-to":{"identifier":"allow-emit-to","description":"Enables the emit_to command without any pre-configured scope.","commands":{"allow":["emit_to"],"deny":[]}},"allow-listen":{"identifier":"allow-listen","description":"Enables the listen command without any pre-configured scope.","commands":{"allow":["listen"],"deny":[]}},"allow-unlisten":{"identifier":"allow-unlisten","description":"Enables the unlisten command without any pre-configured scope.","commands":{"allow":["unlisten"],"deny":[]}},"deny-emit":{"identifier":"deny-emit","description":"Denies the emit command without any pre-configured scope.","commands":{"allow":[],"deny":["emit"]}},"deny-emit-to":{"identifier":"deny-emit-to","description":"Denies the emit_to command without any pre-configured scope.","commands":{"allow":[],"deny":["emit_to"]}},"deny-listen":{"identifier":"deny-listen","description":"Denies the listen command without any pre-configured scope.","commands":{"allow":[],"deny":["listen"]}},"deny-unlisten":{"identifier":"deny-unlisten","description":"Denies the unlisten command without any pre-configured scope.","commands":{"allow":[],"deny":["unlisten"]}}},"permission_sets":{},"global_scope_schema":null},"core:image":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-from-bytes","allow-from-path","allow-rgba","allow-size"]},"permissions":{"allow-from-bytes":{"identifier":"allow-from-bytes","description":"Enables the from_bytes command without any pre-configured scope.","commands":{"allow":["from_bytes"],"deny":[]}},"allow-from-path":{"identifier":"allow-from-path","description":"Enables the from_path command without any pre-configured scope.","commands":{"allow":["from_path"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-rgba":{"identifier":"allow-rgba","description":"Enables the rgba command without any pre-configured scope.","commands":{"allow":["rgba"],"deny":[]}},"allow-size":{"identifier":"allow-size","description":"Enables the size command without any pre-configured scope.","commands":{"allow":["size"],"deny":[]}},"deny-from-bytes":{"identifier":"deny-from-bytes","description":"Denies the from_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["from_bytes"]}},"deny-from-path":{"identifier":"deny-from-path","description":"Denies the from_path command without any pre-configured scope.","commands":{"allow":[],"deny":["from_path"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-rgba":{"identifier":"deny-rgba","description":"Denies the rgba command without any pre-configured scope.","commands":{"allow":[],"deny":["rgba"]}},"deny-size":{"identifier":"deny-size","description":"Denies the size command without any pre-configured scope.","commands":{"allow":[],"deny":["size"]}}},"permission_sets":{},"global_scope_schema":null},"core:menu":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-append","allow-prepend","allow-insert","allow-remove","allow-remove-at","allow-items","allow-get","allow-popup","allow-create-default","allow-set-as-app-menu","allow-set-as-window-menu","allow-text","allow-set-text","allow-is-enabled","allow-set-enabled","allow-set-accelerator","allow-set-as-windows-menu-for-nsapp","allow-set-as-help-menu-for-nsapp","allow-is-checked","allow-set-checked","allow-set-icon"]},"permissions":{"allow-append":{"identifier":"allow-append","description":"Enables the append command without any pre-configured scope.","commands":{"allow":["append"],"deny":[]}},"allow-create-default":{"identifier":"allow-create-default","description":"Enables the create_default command without any pre-configured scope.","commands":{"allow":["create_default"],"deny":[]}},"allow-get":{"identifier":"allow-get","description":"Enables the get command without any pre-configured scope.","commands":{"allow":["get"],"deny":[]}},"allow-insert":{"identifier":"allow-insert","description":"Enables the insert command without any pre-configured scope.","commands":{"allow":["insert"],"deny":[]}},"allow-is-checked":{"identifier":"allow-is-checked","description":"Enables the is_checked command without any pre-configured scope.","commands":{"allow":["is_checked"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-items":{"identifier":"allow-items","description":"Enables the items command without any pre-configured scope.","commands":{"allow":["items"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-popup":{"identifier":"allow-popup","description":"Enables the popup command without any pre-configured scope.","commands":{"allow":["popup"],"deny":[]}},"allow-prepend":{"identifier":"allow-prepend","description":"Enables the prepend command without any pre-configured scope.","commands":{"allow":["prepend"],"deny":[]}},"allow-remove":{"identifier":"allow-remove","description":"Enables the remove command without any pre-configured scope.","commands":{"allow":["remove"],"deny":[]}},"allow-remove-at":{"identifier":"allow-remove-at","description":"Enables the remove_at command without any pre-configured scope.","commands":{"allow":["remove_at"],"deny":[]}},"allow-set-accelerator":{"identifier":"allow-set-accelerator","description":"Enables the set_accelerator command without any pre-configured scope.","commands":{"allow":["set_accelerator"],"deny":[]}},"allow-set-as-app-menu":{"identifier":"allow-set-as-app-menu","description":"Enables the set_as_app_menu command without any pre-configured scope.","commands":{"allow":["set_as_app_menu"],"deny":[]}},"allow-set-as-help-menu-for-nsapp":{"identifier":"allow-set-as-help-menu-for-nsapp","description":"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_help_menu_for_nsapp"],"deny":[]}},"allow-set-as-window-menu":{"identifier":"allow-set-as-window-menu","description":"Enables the set_as_window_menu command without any pre-configured scope.","commands":{"allow":["set_as_window_menu"],"deny":[]}},"allow-set-as-windows-menu-for-nsapp":{"identifier":"allow-set-as-windows-menu-for-nsapp","description":"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_windows_menu_for_nsapp"],"deny":[]}},"allow-set-checked":{"identifier":"allow-set-checked","description":"Enables the set_checked command without any pre-configured scope.","commands":{"allow":["set_checked"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-text":{"identifier":"allow-set-text","description":"Enables the set_text command without any pre-configured scope.","commands":{"allow":["set_text"],"deny":[]}},"allow-text":{"identifier":"allow-text","description":"Enables the text command without any pre-configured scope.","commands":{"allow":["text"],"deny":[]}},"deny-append":{"identifier":"deny-append","description":"Denies the append command without any pre-configured scope.","commands":{"allow":[],"deny":["append"]}},"deny-create-default":{"identifier":"deny-create-default","description":"Denies the create_default command without any pre-configured scope.","commands":{"allow":[],"deny":["create_default"]}},"deny-get":{"identifier":"deny-get","description":"Denies the get command without any pre-configured scope.","commands":{"allow":[],"deny":["get"]}},"deny-insert":{"identifier":"deny-insert","description":"Denies the insert command without any pre-configured scope.","commands":{"allow":[],"deny":["insert"]}},"deny-is-checked":{"identifier":"deny-is-checked","description":"Denies the is_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["is_checked"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-items":{"identifier":"deny-items","description":"Denies the items command without any pre-configured scope.","commands":{"allow":[],"deny":["items"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-popup":{"identifier":"deny-popup","description":"Denies the popup command without any pre-configured scope.","commands":{"allow":[],"deny":["popup"]}},"deny-prepend":{"identifier":"deny-prepend","description":"Denies the prepend command without any pre-configured scope.","commands":{"allow":[],"deny":["prepend"]}},"deny-remove":{"identifier":"deny-remove","description":"Denies the remove command without any pre-configured scope.","commands":{"allow":[],"deny":["remove"]}},"deny-remove-at":{"identifier":"deny-remove-at","description":"Denies the remove_at command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_at"]}},"deny-set-accelerator":{"identifier":"deny-set-accelerator","description":"Denies the set_accelerator command without any pre-configured scope.","commands":{"allow":[],"deny":["set_accelerator"]}},"deny-set-as-app-menu":{"identifier":"deny-set-as-app-menu","description":"Denies the set_as_app_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_app_menu"]}},"deny-set-as-help-menu-for-nsapp":{"identifier":"deny-set-as-help-menu-for-nsapp","description":"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_help_menu_for_nsapp"]}},"deny-set-as-window-menu":{"identifier":"deny-set-as-window-menu","description":"Denies the set_as_window_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_window_menu"]}},"deny-set-as-windows-menu-for-nsapp":{"identifier":"deny-set-as-windows-menu-for-nsapp","description":"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_windows_menu_for_nsapp"]}},"deny-set-checked":{"identifier":"deny-set-checked","description":"Denies the set_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["set_checked"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-text":{"identifier":"deny-set-text","description":"Denies the set_text command without any pre-configured scope.","commands":{"allow":[],"deny":["set_text"]}},"deny-text":{"identifier":"deny-text","description":"Denies the text command without any pre-configured scope.","commands":{"allow":[],"deny":["text"]}}},"permission_sets":{},"global_scope_schema":null},"core:path":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-resolve-directory","allow-resolve","allow-normalize","allow-join","allow-dirname","allow-extname","allow-basename","allow-is-absolute"]},"permissions":{"allow-basename":{"identifier":"allow-basename","description":"Enables the basename command without any pre-configured scope.","commands":{"allow":["basename"],"deny":[]}},"allow-dirname":{"identifier":"allow-dirname","description":"Enables the dirname command without any pre-configured scope.","commands":{"allow":["dirname"],"deny":[]}},"allow-extname":{"identifier":"allow-extname","description":"Enables the extname command without any pre-configured scope.","commands":{"allow":["extname"],"deny":[]}},"allow-is-absolute":{"identifier":"allow-is-absolute","description":"Enables the is_absolute command without any pre-configured scope.","commands":{"allow":["is_absolute"],"deny":[]}},"allow-join":{"identifier":"allow-join","description":"Enables the join command without any pre-configured scope.","commands":{"allow":["join"],"deny":[]}},"allow-normalize":{"identifier":"allow-normalize","description":"Enables the normalize command without any pre-configured scope.","commands":{"allow":["normalize"],"deny":[]}},"allow-resolve":{"identifier":"allow-resolve","description":"Enables the resolve command without any pre-configured scope.","commands":{"allow":["resolve"],"deny":[]}},"allow-resolve-directory":{"identifier":"allow-resolve-directory","description":"Enables the resolve_directory command without any pre-configured scope.","commands":{"allow":["resolve_directory"],"deny":[]}},"deny-basename":{"identifier":"deny-basename","description":"Denies the basename command without any pre-configured scope.","commands":{"allow":[],"deny":["basename"]}},"deny-dirname":{"identifier":"deny-dirname","description":"Denies the dirname command without any pre-configured scope.","commands":{"allow":[],"deny":["dirname"]}},"deny-extname":{"identifier":"deny-extname","description":"Denies the extname command without any pre-configured scope.","commands":{"allow":[],"deny":["extname"]}},"deny-is-absolute":{"identifier":"deny-is-absolute","description":"Denies the is_absolute command without any pre-configured scope.","commands":{"allow":[],"deny":["is_absolute"]}},"deny-join":{"identifier":"deny-join","description":"Denies the join command without any pre-configured scope.","commands":{"allow":[],"deny":["join"]}},"deny-normalize":{"identifier":"deny-normalize","description":"Denies the normalize command without any pre-configured scope.","commands":{"allow":[],"deny":["normalize"]}},"deny-resolve":{"identifier":"deny-resolve","description":"Denies the resolve command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve"]}},"deny-resolve-directory":{"identifier":"deny-resolve-directory","description":"Denies the resolve_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve_directory"]}}},"permission_sets":{},"global_scope_schema":null},"core:resources":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-close"]},"permissions":{"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}}},"permission_sets":{},"global_scope_schema":null},"core:tray":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-get-by-id","allow-remove-by-id","allow-set-icon","allow-set-menu","allow-set-tooltip","allow-set-title","allow-set-visible","allow-set-temp-dir-path","allow-set-icon-as-template","allow-set-icon-with-as-template","allow-set-show-menu-on-left-click"]},"permissions":{"allow-get-by-id":{"identifier":"allow-get-by-id","description":"Enables the get_by_id command without any pre-configured scope.","commands":{"allow":["get_by_id"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-remove-by-id":{"identifier":"allow-remove-by-id","description":"Enables the remove_by_id command without any pre-configured scope.","commands":{"allow":["remove_by_id"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-icon-as-template":{"identifier":"allow-set-icon-as-template","description":"Enables the set_icon_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_as_template"],"deny":[]}},"allow-set-icon-with-as-template":{"identifier":"allow-set-icon-with-as-template","description":"Enables the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_with_as_template"],"deny":[]}},"allow-set-menu":{"identifier":"allow-set-menu","description":"Enables the set_menu command without any pre-configured scope.","commands":{"allow":["set_menu"],"deny":[]}},"allow-set-show-menu-on-left-click":{"identifier":"allow-set-show-menu-on-left-click","description":"Enables the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":["set_show_menu_on_left_click"],"deny":[]}},"allow-set-temp-dir-path":{"identifier":"allow-set-temp-dir-path","description":"Enables the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":["set_temp_dir_path"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-tooltip":{"identifier":"allow-set-tooltip","description":"Enables the set_tooltip command without any pre-configured scope.","commands":{"allow":["set_tooltip"],"deny":[]}},"allow-set-visible":{"identifier":"allow-set-visible","description":"Enables the set_visible command without any pre-configured scope.","commands":{"allow":["set_visible"],"deny":[]}},"deny-get-by-id":{"identifier":"deny-get-by-id","description":"Denies the get_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["get_by_id"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-remove-by-id":{"identifier":"deny-remove-by-id","description":"Denies the remove_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_by_id"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-icon-as-template":{"identifier":"deny-set-icon-as-template","description":"Denies the set_icon_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_as_template"]}},"deny-set-icon-with-as-template":{"identifier":"deny-set-icon-with-as-template","description":"Denies the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_with_as_template"]}},"deny-set-menu":{"identifier":"deny-set-menu","description":"Denies the set_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_menu"]}},"deny-set-show-menu-on-left-click":{"identifier":"deny-set-show-menu-on-left-click","description":"Denies the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":[],"deny":["set_show_menu_on_left_click"]}},"deny-set-temp-dir-path":{"identifier":"deny-set-temp-dir-path","description":"Denies the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":[],"deny":["set_temp_dir_path"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-tooltip":{"identifier":"deny-set-tooltip","description":"Denies the set_tooltip command without any pre-configured scope.","commands":{"allow":[],"deny":["set_tooltip"]}},"deny-set-visible":{"identifier":"deny-set-visible","description":"Denies the set_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible"]}}},"permission_sets":{},"global_scope_schema":null},"core:webview":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-webviews","allow-webview-position","allow-webview-size","allow-internal-toggle-devtools"]},"permissions":{"allow-clear-all-browsing-data":{"identifier":"allow-clear-all-browsing-data","description":"Enables the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":["clear_all_browsing_data"],"deny":[]}},"allow-create-webview":{"identifier":"allow-create-webview","description":"Enables the create_webview command without any pre-configured scope.","commands":{"allow":["create_webview"],"deny":[]}},"allow-create-webview-window":{"identifier":"allow-create-webview-window","description":"Enables the create_webview_window command without any pre-configured scope.","commands":{"allow":["create_webview_window"],"deny":[]}},"allow-get-all-webviews":{"identifier":"allow-get-all-webviews","description":"Enables the get_all_webviews command without any pre-configured scope.","commands":{"allow":["get_all_webviews"],"deny":[]}},"allow-internal-toggle-devtools":{"identifier":"allow-internal-toggle-devtools","description":"Enables the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":["internal_toggle_devtools"],"deny":[]}},"allow-print":{"identifier":"allow-print","description":"Enables the print command without any pre-configured scope.","commands":{"allow":["print"],"deny":[]}},"allow-reparent":{"identifier":"allow-reparent","description":"Enables the reparent command without any pre-configured scope.","commands":{"allow":["reparent"],"deny":[]}},"allow-set-webview-auto-resize":{"identifier":"allow-set-webview-auto-resize","description":"Enables the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":["set_webview_auto_resize"],"deny":[]}},"allow-set-webview-background-color":{"identifier":"allow-set-webview-background-color","description":"Enables the set_webview_background_color command without any pre-configured scope.","commands":{"allow":["set_webview_background_color"],"deny":[]}},"allow-set-webview-focus":{"identifier":"allow-set-webview-focus","description":"Enables the set_webview_focus command without any pre-configured scope.","commands":{"allow":["set_webview_focus"],"deny":[]}},"allow-set-webview-position":{"identifier":"allow-set-webview-position","description":"Enables the set_webview_position command without any pre-configured scope.","commands":{"allow":["set_webview_position"],"deny":[]}},"allow-set-webview-size":{"identifier":"allow-set-webview-size","description":"Enables the set_webview_size command without any pre-configured scope.","commands":{"allow":["set_webview_size"],"deny":[]}},"allow-set-webview-zoom":{"identifier":"allow-set-webview-zoom","description":"Enables the set_webview_zoom command without any pre-configured scope.","commands":{"allow":["set_webview_zoom"],"deny":[]}},"allow-webview-close":{"identifier":"allow-webview-close","description":"Enables the webview_close command without any pre-configured scope.","commands":{"allow":["webview_close"],"deny":[]}},"allow-webview-hide":{"identifier":"allow-webview-hide","description":"Enables the webview_hide command without any pre-configured scope.","commands":{"allow":["webview_hide"],"deny":[]}},"allow-webview-position":{"identifier":"allow-webview-position","description":"Enables the webview_position command without any pre-configured scope.","commands":{"allow":["webview_position"],"deny":[]}},"allow-webview-show":{"identifier":"allow-webview-show","description":"Enables the webview_show command without any pre-configured scope.","commands":{"allow":["webview_show"],"deny":[]}},"allow-webview-size":{"identifier":"allow-webview-size","description":"Enables the webview_size command without any pre-configured scope.","commands":{"allow":["webview_size"],"deny":[]}},"deny-clear-all-browsing-data":{"identifier":"deny-clear-all-browsing-data","description":"Denies the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_all_browsing_data"]}},"deny-create-webview":{"identifier":"deny-create-webview","description":"Denies the create_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview"]}},"deny-create-webview-window":{"identifier":"deny-create-webview-window","description":"Denies the create_webview_window command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview_window"]}},"deny-get-all-webviews":{"identifier":"deny-get-all-webviews","description":"Denies the get_all_webviews command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_webviews"]}},"deny-internal-toggle-devtools":{"identifier":"deny-internal-toggle-devtools","description":"Denies the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_devtools"]}},"deny-print":{"identifier":"deny-print","description":"Denies the print command without any pre-configured scope.","commands":{"allow":[],"deny":["print"]}},"deny-reparent":{"identifier":"deny-reparent","description":"Denies the reparent command without any pre-configured scope.","commands":{"allow":[],"deny":["reparent"]}},"deny-set-webview-auto-resize":{"identifier":"deny-set-webview-auto-resize","description":"Denies the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_auto_resize"]}},"deny-set-webview-background-color":{"identifier":"deny-set-webview-background-color","description":"Denies the set_webview_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_background_color"]}},"deny-set-webview-focus":{"identifier":"deny-set-webview-focus","description":"Denies the set_webview_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_focus"]}},"deny-set-webview-position":{"identifier":"deny-set-webview-position","description":"Denies the set_webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_position"]}},"deny-set-webview-size":{"identifier":"deny-set-webview-size","description":"Denies the set_webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_size"]}},"deny-set-webview-zoom":{"identifier":"deny-set-webview-zoom","description":"Denies the set_webview_zoom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_zoom"]}},"deny-webview-close":{"identifier":"deny-webview-close","description":"Denies the webview_close command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_close"]}},"deny-webview-hide":{"identifier":"deny-webview-hide","description":"Denies the webview_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_hide"]}},"deny-webview-position":{"identifier":"deny-webview-position","description":"Denies the webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_position"]}},"deny-webview-show":{"identifier":"deny-webview-show","description":"Denies the webview_show command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_show"]}},"deny-webview-size":{"identifier":"deny-webview-size","description":"Denies the webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_size"]}}},"permission_sets":{},"global_scope_schema":null},"core:window":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-windows","allow-scale-factor","allow-inner-position","allow-outer-position","allow-inner-size","allow-outer-size","allow-is-fullscreen","allow-is-minimized","allow-is-maximized","allow-is-focused","allow-is-decorated","allow-is-resizable","allow-is-maximizable","allow-is-minimizable","allow-is-closable","allow-is-visible","allow-is-enabled","allow-title","allow-current-monitor","allow-primary-monitor","allow-monitor-from-point","allow-available-monitors","allow-cursor-position","allow-theme","allow-is-always-on-top","allow-activity-name","allow-scene-identifier","allow-internal-toggle-maximize"]},"permissions":{"allow-activity-name":{"identifier":"allow-activity-name","description":"Enables the activity_name command without any pre-configured scope.","commands":{"allow":["activity_name"],"deny":[]}},"allow-available-monitors":{"identifier":"allow-available-monitors","description":"Enables the available_monitors command without any pre-configured scope.","commands":{"allow":["available_monitors"],"deny":[]}},"allow-center":{"identifier":"allow-center","description":"Enables the center command without any pre-configured scope.","commands":{"allow":["center"],"deny":[]}},"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"allow-create":{"identifier":"allow-create","description":"Enables the create command without any pre-configured scope.","commands":{"allow":["create"],"deny":[]}},"allow-current-monitor":{"identifier":"allow-current-monitor","description":"Enables the current_monitor command without any pre-configured scope.","commands":{"allow":["current_monitor"],"deny":[]}},"allow-cursor-position":{"identifier":"allow-cursor-position","description":"Enables the cursor_position command without any pre-configured scope.","commands":{"allow":["cursor_position"],"deny":[]}},"allow-destroy":{"identifier":"allow-destroy","description":"Enables the destroy command without any pre-configured scope.","commands":{"allow":["destroy"],"deny":[]}},"allow-get-all-windows":{"identifier":"allow-get-all-windows","description":"Enables the get_all_windows command without any pre-configured scope.","commands":{"allow":["get_all_windows"],"deny":[]}},"allow-hide":{"identifier":"allow-hide","description":"Enables the hide command without any pre-configured scope.","commands":{"allow":["hide"],"deny":[]}},"allow-inner-position":{"identifier":"allow-inner-position","description":"Enables the inner_position command without any pre-configured scope.","commands":{"allow":["inner_position"],"deny":[]}},"allow-inner-size":{"identifier":"allow-inner-size","description":"Enables the inner_size command without any pre-configured scope.","commands":{"allow":["inner_size"],"deny":[]}},"allow-internal-toggle-maximize":{"identifier":"allow-internal-toggle-maximize","description":"Enables the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":["internal_toggle_maximize"],"deny":[]}},"allow-is-always-on-top":{"identifier":"allow-is-always-on-top","description":"Enables the is_always_on_top command without any pre-configured scope.","commands":{"allow":["is_always_on_top"],"deny":[]}},"allow-is-closable":{"identifier":"allow-is-closable","description":"Enables the is_closable command without any pre-configured scope.","commands":{"allow":["is_closable"],"deny":[]}},"allow-is-decorated":{"identifier":"allow-is-decorated","description":"Enables the is_decorated command without any pre-configured scope.","commands":{"allow":["is_decorated"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-is-focused":{"identifier":"allow-is-focused","description":"Enables the is_focused command without any pre-configured scope.","commands":{"allow":["is_focused"],"deny":[]}},"allow-is-fullscreen":{"identifier":"allow-is-fullscreen","description":"Enables the is_fullscreen command without any pre-configured scope.","commands":{"allow":["is_fullscreen"],"deny":[]}},"allow-is-maximizable":{"identifier":"allow-is-maximizable","description":"Enables the is_maximizable command without any pre-configured scope.","commands":{"allow":["is_maximizable"],"deny":[]}},"allow-is-maximized":{"identifier":"allow-is-maximized","description":"Enables the is_maximized command without any pre-configured scope.","commands":{"allow":["is_maximized"],"deny":[]}},"allow-is-minimizable":{"identifier":"allow-is-minimizable","description":"Enables the is_minimizable command without any pre-configured scope.","commands":{"allow":["is_minimizable"],"deny":[]}},"allow-is-minimized":{"identifier":"allow-is-minimized","description":"Enables the is_minimized command without any pre-configured scope.","commands":{"allow":["is_minimized"],"deny":[]}},"allow-is-resizable":{"identifier":"allow-is-resizable","description":"Enables the is_resizable command without any pre-configured scope.","commands":{"allow":["is_resizable"],"deny":[]}},"allow-is-visible":{"identifier":"allow-is-visible","description":"Enables the is_visible command without any pre-configured scope.","commands":{"allow":["is_visible"],"deny":[]}},"allow-maximize":{"identifier":"allow-maximize","description":"Enables the maximize command without any pre-configured scope.","commands":{"allow":["maximize"],"deny":[]}},"allow-minimize":{"identifier":"allow-minimize","description":"Enables the minimize command without any pre-configured scope.","commands":{"allow":["minimize"],"deny":[]}},"allow-monitor-from-point":{"identifier":"allow-monitor-from-point","description":"Enables the monitor_from_point command without any pre-configured scope.","commands":{"allow":["monitor_from_point"],"deny":[]}},"allow-outer-position":{"identifier":"allow-outer-position","description":"Enables the outer_position command without any pre-configured scope.","commands":{"allow":["outer_position"],"deny":[]}},"allow-outer-size":{"identifier":"allow-outer-size","description":"Enables the outer_size command without any pre-configured scope.","commands":{"allow":["outer_size"],"deny":[]}},"allow-primary-monitor":{"identifier":"allow-primary-monitor","description":"Enables the primary_monitor command without any pre-configured scope.","commands":{"allow":["primary_monitor"],"deny":[]}},"allow-request-user-attention":{"identifier":"allow-request-user-attention","description":"Enables the request_user_attention command without any pre-configured scope.","commands":{"allow":["request_user_attention"],"deny":[]}},"allow-scale-factor":{"identifier":"allow-scale-factor","description":"Enables the scale_factor command without any pre-configured scope.","commands":{"allow":["scale_factor"],"deny":[]}},"allow-scene-identifier":{"identifier":"allow-scene-identifier","description":"Enables the scene_identifier command without any pre-configured scope.","commands":{"allow":["scene_identifier"],"deny":[]}},"allow-set-always-on-bottom":{"identifier":"allow-set-always-on-bottom","description":"Enables the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":["set_always_on_bottom"],"deny":[]}},"allow-set-always-on-top":{"identifier":"allow-set-always-on-top","description":"Enables the set_always_on_top command without any pre-configured scope.","commands":{"allow":["set_always_on_top"],"deny":[]}},"allow-set-background-color":{"identifier":"allow-set-background-color","description":"Enables the set_background_color command without any pre-configured scope.","commands":{"allow":["set_background_color"],"deny":[]}},"allow-set-badge-count":{"identifier":"allow-set-badge-count","description":"Enables the set_badge_count command without any pre-configured scope.","commands":{"allow":["set_badge_count"],"deny":[]}},"allow-set-badge-label":{"identifier":"allow-set-badge-label","description":"Enables the set_badge_label command without any pre-configured scope.","commands":{"allow":["set_badge_label"],"deny":[]}},"allow-set-closable":{"identifier":"allow-set-closable","description":"Enables the set_closable command without any pre-configured scope.","commands":{"allow":["set_closable"],"deny":[]}},"allow-set-content-protected":{"identifier":"allow-set-content-protected","description":"Enables the set_content_protected command without any pre-configured scope.","commands":{"allow":["set_content_protected"],"deny":[]}},"allow-set-cursor-grab":{"identifier":"allow-set-cursor-grab","description":"Enables the set_cursor_grab command without any pre-configured scope.","commands":{"allow":["set_cursor_grab"],"deny":[]}},"allow-set-cursor-icon":{"identifier":"allow-set-cursor-icon","description":"Enables the set_cursor_icon command without any pre-configured scope.","commands":{"allow":["set_cursor_icon"],"deny":[]}},"allow-set-cursor-position":{"identifier":"allow-set-cursor-position","description":"Enables the set_cursor_position command without any pre-configured scope.","commands":{"allow":["set_cursor_position"],"deny":[]}},"allow-set-cursor-visible":{"identifier":"allow-set-cursor-visible","description":"Enables the set_cursor_visible command without any pre-configured scope.","commands":{"allow":["set_cursor_visible"],"deny":[]}},"allow-set-decorations":{"identifier":"allow-set-decorations","description":"Enables the set_decorations command without any pre-configured scope.","commands":{"allow":["set_decorations"],"deny":[]}},"allow-set-effects":{"identifier":"allow-set-effects","description":"Enables the set_effects command without any pre-configured scope.","commands":{"allow":["set_effects"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-focus":{"identifier":"allow-set-focus","description":"Enables the set_focus command without any pre-configured scope.","commands":{"allow":["set_focus"],"deny":[]}},"allow-set-focusable":{"identifier":"allow-set-focusable","description":"Enables the set_focusable command without any pre-configured scope.","commands":{"allow":["set_focusable"],"deny":[]}},"allow-set-fullscreen":{"identifier":"allow-set-fullscreen","description":"Enables the set_fullscreen command without any pre-configured scope.","commands":{"allow":["set_fullscreen"],"deny":[]}},"allow-set-fullscreen-on-monitor":{"identifier":"allow-set-fullscreen-on-monitor","description":"Enables the set_fullscreen_on_monitor command without any pre-configured scope.","commands":{"allow":["set_fullscreen_on_monitor"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-ignore-cursor-events":{"identifier":"allow-set-ignore-cursor-events","description":"Enables the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":["set_ignore_cursor_events"],"deny":[]}},"allow-set-max-size":{"identifier":"allow-set-max-size","description":"Enables the set_max_size command without any pre-configured scope.","commands":{"allow":["set_max_size"],"deny":[]}},"allow-set-maximizable":{"identifier":"allow-set-maximizable","description":"Enables the set_maximizable command without any pre-configured scope.","commands":{"allow":["set_maximizable"],"deny":[]}},"allow-set-min-size":{"identifier":"allow-set-min-size","description":"Enables the set_min_size command without any pre-configured scope.","commands":{"allow":["set_min_size"],"deny":[]}},"allow-set-minimizable":{"identifier":"allow-set-minimizable","description":"Enables the set_minimizable command without any pre-configured scope.","commands":{"allow":["set_minimizable"],"deny":[]}},"allow-set-overlay-icon":{"identifier":"allow-set-overlay-icon","description":"Enables the set_overlay_icon command without any pre-configured scope.","commands":{"allow":["set_overlay_icon"],"deny":[]}},"allow-set-position":{"identifier":"allow-set-position","description":"Enables the set_position command without any pre-configured scope.","commands":{"allow":["set_position"],"deny":[]}},"allow-set-progress-bar":{"identifier":"allow-set-progress-bar","description":"Enables the set_progress_bar command without any pre-configured scope.","commands":{"allow":["set_progress_bar"],"deny":[]}},"allow-set-resizable":{"identifier":"allow-set-resizable","description":"Enables the set_resizable command without any pre-configured scope.","commands":{"allow":["set_resizable"],"deny":[]}},"allow-set-shadow":{"identifier":"allow-set-shadow","description":"Enables the set_shadow command without any pre-configured scope.","commands":{"allow":["set_shadow"],"deny":[]}},"allow-set-simple-fullscreen":{"identifier":"allow-set-simple-fullscreen","description":"Enables the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":["set_simple_fullscreen"],"deny":[]}},"allow-set-size":{"identifier":"allow-set-size","description":"Enables the set_size command without any pre-configured scope.","commands":{"allow":["set_size"],"deny":[]}},"allow-set-size-constraints":{"identifier":"allow-set-size-constraints","description":"Enables the set_size_constraints command without any pre-configured scope.","commands":{"allow":["set_size_constraints"],"deny":[]}},"allow-set-skip-taskbar":{"identifier":"allow-set-skip-taskbar","description":"Enables the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":["set_skip_taskbar"],"deny":[]}},"allow-set-theme":{"identifier":"allow-set-theme","description":"Enables the set_theme command without any pre-configured scope.","commands":{"allow":["set_theme"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-title-bar-style":{"identifier":"allow-set-title-bar-style","description":"Enables the set_title_bar_style command without any pre-configured scope.","commands":{"allow":["set_title_bar_style"],"deny":[]}},"allow-set-visible-on-all-workspaces":{"identifier":"allow-set-visible-on-all-workspaces","description":"Enables the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":["set_visible_on_all_workspaces"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"allow-start-dragging":{"identifier":"allow-start-dragging","description":"Enables the start_dragging command without any pre-configured scope.","commands":{"allow":["start_dragging"],"deny":[]}},"allow-start-resize-dragging":{"identifier":"allow-start-resize-dragging","description":"Enables the start_resize_dragging command without any pre-configured scope.","commands":{"allow":["start_resize_dragging"],"deny":[]}},"allow-theme":{"identifier":"allow-theme","description":"Enables the theme command without any pre-configured scope.","commands":{"allow":["theme"],"deny":[]}},"allow-title":{"identifier":"allow-title","description":"Enables the title command without any pre-configured scope.","commands":{"allow":["title"],"deny":[]}},"allow-toggle-maximize":{"identifier":"allow-toggle-maximize","description":"Enables the toggle_maximize command without any pre-configured scope.","commands":{"allow":["toggle_maximize"],"deny":[]}},"allow-unmaximize":{"identifier":"allow-unmaximize","description":"Enables the unmaximize command without any pre-configured scope.","commands":{"allow":["unmaximize"],"deny":[]}},"allow-unminimize":{"identifier":"allow-unminimize","description":"Enables the unminimize command without any pre-configured scope.","commands":{"allow":["unminimize"],"deny":[]}},"deny-activity-name":{"identifier":"deny-activity-name","description":"Denies the activity_name command without any pre-configured scope.","commands":{"allow":[],"deny":["activity_name"]}},"deny-available-monitors":{"identifier":"deny-available-monitors","description":"Denies the available_monitors command without any pre-configured scope.","commands":{"allow":[],"deny":["available_monitors"]}},"deny-center":{"identifier":"deny-center","description":"Denies the center command without any pre-configured scope.","commands":{"allow":[],"deny":["center"]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}},"deny-create":{"identifier":"deny-create","description":"Denies the create command without any pre-configured scope.","commands":{"allow":[],"deny":["create"]}},"deny-current-monitor":{"identifier":"deny-current-monitor","description":"Denies the current_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["current_monitor"]}},"deny-cursor-position":{"identifier":"deny-cursor-position","description":"Denies the cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["cursor_position"]}},"deny-destroy":{"identifier":"deny-destroy","description":"Denies the destroy command without any pre-configured scope.","commands":{"allow":[],"deny":["destroy"]}},"deny-get-all-windows":{"identifier":"deny-get-all-windows","description":"Denies the get_all_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_windows"]}},"deny-hide":{"identifier":"deny-hide","description":"Denies the hide command without any pre-configured scope.","commands":{"allow":[],"deny":["hide"]}},"deny-inner-position":{"identifier":"deny-inner-position","description":"Denies the inner_position command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_position"]}},"deny-inner-size":{"identifier":"deny-inner-size","description":"Denies the inner_size command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_size"]}},"deny-internal-toggle-maximize":{"identifier":"deny-internal-toggle-maximize","description":"Denies the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_maximize"]}},"deny-is-always-on-top":{"identifier":"deny-is-always-on-top","description":"Denies the is_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["is_always_on_top"]}},"deny-is-closable":{"identifier":"deny-is-closable","description":"Denies the is_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_closable"]}},"deny-is-decorated":{"identifier":"deny-is-decorated","description":"Denies the is_decorated command without any pre-configured scope.","commands":{"allow":[],"deny":["is_decorated"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-is-focused":{"identifier":"deny-is-focused","description":"Denies the is_focused command without any pre-configured scope.","commands":{"allow":[],"deny":["is_focused"]}},"deny-is-fullscreen":{"identifier":"deny-is-fullscreen","description":"Denies the is_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["is_fullscreen"]}},"deny-is-maximizable":{"identifier":"deny-is-maximizable","description":"Denies the is_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximizable"]}},"deny-is-maximized":{"identifier":"deny-is-maximized","description":"Denies the is_maximized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximized"]}},"deny-is-minimizable":{"identifier":"deny-is-minimizable","description":"Denies the is_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimizable"]}},"deny-is-minimized":{"identifier":"deny-is-minimized","description":"Denies the is_minimized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimized"]}},"deny-is-resizable":{"identifier":"deny-is-resizable","description":"Denies the is_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_resizable"]}},"deny-is-visible":{"identifier":"deny-is-visible","description":"Denies the is_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["is_visible"]}},"deny-maximize":{"identifier":"deny-maximize","description":"Denies the maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["maximize"]}},"deny-minimize":{"identifier":"deny-minimize","description":"Denies the minimize command without any pre-configured scope.","commands":{"allow":[],"deny":["minimize"]}},"deny-monitor-from-point":{"identifier":"deny-monitor-from-point","description":"Denies the monitor_from_point command without any pre-configured scope.","commands":{"allow":[],"deny":["monitor_from_point"]}},"deny-outer-position":{"identifier":"deny-outer-position","description":"Denies the outer_position command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_position"]}},"deny-outer-size":{"identifier":"deny-outer-size","description":"Denies the outer_size command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_size"]}},"deny-primary-monitor":{"identifier":"deny-primary-monitor","description":"Denies the primary_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["primary_monitor"]}},"deny-request-user-attention":{"identifier":"deny-request-user-attention","description":"Denies the request_user_attention command without any pre-configured scope.","commands":{"allow":[],"deny":["request_user_attention"]}},"deny-scale-factor":{"identifier":"deny-scale-factor","description":"Denies the scale_factor command without any pre-configured scope.","commands":{"allow":[],"deny":["scale_factor"]}},"deny-scene-identifier":{"identifier":"deny-scene-identifier","description":"Denies the scene_identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["scene_identifier"]}},"deny-set-always-on-bottom":{"identifier":"deny-set-always-on-bottom","description":"Denies the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_bottom"]}},"deny-set-always-on-top":{"identifier":"deny-set-always-on-top","description":"Denies the set_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_top"]}},"deny-set-background-color":{"identifier":"deny-set-background-color","description":"Denies the set_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_background_color"]}},"deny-set-badge-count":{"identifier":"deny-set-badge-count","description":"Denies the set_badge_count command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_count"]}},"deny-set-badge-label":{"identifier":"deny-set-badge-label","description":"Denies the set_badge_label command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_label"]}},"deny-set-closable":{"identifier":"deny-set-closable","description":"Denies the set_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_closable"]}},"deny-set-content-protected":{"identifier":"deny-set-content-protected","description":"Denies the set_content_protected command without any pre-configured scope.","commands":{"allow":[],"deny":["set_content_protected"]}},"deny-set-cursor-grab":{"identifier":"deny-set-cursor-grab","description":"Denies the set_cursor_grab command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_grab"]}},"deny-set-cursor-icon":{"identifier":"deny-set-cursor-icon","description":"Denies the set_cursor_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_icon"]}},"deny-set-cursor-position":{"identifier":"deny-set-cursor-position","description":"Denies the set_cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_position"]}},"deny-set-cursor-visible":{"identifier":"deny-set-cursor-visible","description":"Denies the set_cursor_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_visible"]}},"deny-set-decorations":{"identifier":"deny-set-decorations","description":"Denies the set_decorations command without any pre-configured scope.","commands":{"allow":[],"deny":["set_decorations"]}},"deny-set-effects":{"identifier":"deny-set-effects","description":"Denies the set_effects command without any pre-configured scope.","commands":{"allow":[],"deny":["set_effects"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-focus":{"identifier":"deny-set-focus","description":"Denies the set_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focus"]}},"deny-set-focusable":{"identifier":"deny-set-focusable","description":"Denies the set_focusable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focusable"]}},"deny-set-fullscreen":{"identifier":"deny-set-fullscreen","description":"Denies the set_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen"]}},"deny-set-fullscreen-on-monitor":{"identifier":"deny-set-fullscreen-on-monitor","description":"Denies the set_fullscreen_on_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen_on_monitor"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-ignore-cursor-events":{"identifier":"deny-set-ignore-cursor-events","description":"Denies the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":[],"deny":["set_ignore_cursor_events"]}},"deny-set-max-size":{"identifier":"deny-set-max-size","description":"Denies the set_max_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_max_size"]}},"deny-set-maximizable":{"identifier":"deny-set-maximizable","description":"Denies the set_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_maximizable"]}},"deny-set-min-size":{"identifier":"deny-set-min-size","description":"Denies the set_min_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_min_size"]}},"deny-set-minimizable":{"identifier":"deny-set-minimizable","description":"Denies the set_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_minimizable"]}},"deny-set-overlay-icon":{"identifier":"deny-set-overlay-icon","description":"Denies the set_overlay_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_overlay_icon"]}},"deny-set-position":{"identifier":"deny-set-position","description":"Denies the set_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_position"]}},"deny-set-progress-bar":{"identifier":"deny-set-progress-bar","description":"Denies the set_progress_bar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_progress_bar"]}},"deny-set-resizable":{"identifier":"deny-set-resizable","description":"Denies the set_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_resizable"]}},"deny-set-shadow":{"identifier":"deny-set-shadow","description":"Denies the set_shadow command without any pre-configured scope.","commands":{"allow":[],"deny":["set_shadow"]}},"deny-set-simple-fullscreen":{"identifier":"deny-set-simple-fullscreen","description":"Denies the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_simple_fullscreen"]}},"deny-set-size":{"identifier":"deny-set-size","description":"Denies the set_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size"]}},"deny-set-size-constraints":{"identifier":"deny-set-size-constraints","description":"Denies the set_size_constraints command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size_constraints"]}},"deny-set-skip-taskbar":{"identifier":"deny-set-skip-taskbar","description":"Denies the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_skip_taskbar"]}},"deny-set-theme":{"identifier":"deny-set-theme","description":"Denies the set_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_theme"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-title-bar-style":{"identifier":"deny-set-title-bar-style","description":"Denies the set_title_bar_style command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title_bar_style"]}},"deny-set-visible-on-all-workspaces":{"identifier":"deny-set-visible-on-all-workspaces","description":"Denies the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible_on_all_workspaces"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}},"deny-start-dragging":{"identifier":"deny-start-dragging","description":"Denies the start_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_dragging"]}},"deny-start-resize-dragging":{"identifier":"deny-start-resize-dragging","description":"Denies the start_resize_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_resize_dragging"]}},"deny-theme":{"identifier":"deny-theme","description":"Denies the theme command without any pre-configured scope.","commands":{"allow":[],"deny":["theme"]}},"deny-title":{"identifier":"deny-title","description":"Denies the title command without any pre-configured scope.","commands":{"allow":[],"deny":["title"]}},"deny-toggle-maximize":{"identifier":"deny-toggle-maximize","description":"Denies the toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["toggle_maximize"]}},"deny-unmaximize":{"identifier":"deny-unmaximize","description":"Denies the unmaximize command without any pre-configured scope.","commands":{"allow":[],"deny":["unmaximize"]}},"deny-unminimize":{"identifier":"deny-unminimize","description":"Denies the unminimize command without any pre-configured scope.","commands":{"allow":[],"deny":["unminimize"]}}},"permission_sets":{},"global_scope_schema":null},"dialog":{"default_permission":{"identifier":"default","description":"This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n","permissions":["allow-message","allow-save","allow-open"]},"permissions":{"allow-ask":{"identifier":"allow-ask","description":"Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)","commands":{"allow":["message"],"deny":[]}},"allow-confirm":{"identifier":"allow-confirm","description":"Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)","commands":{"allow":["message"],"deny":[]}},"allow-message":{"identifier":"allow-message","description":"Enables the message command without any pre-configured scope.","commands":{"allow":["message"],"deny":[]}},"allow-open":{"identifier":"allow-open","description":"Enables the open command without any pre-configured scope.","commands":{"allow":["open"],"deny":[]}},"allow-save":{"identifier":"allow-save","description":"Enables the save command without any pre-configured scope.","commands":{"allow":["save"],"deny":[]}},"deny-ask":{"identifier":"deny-ask","description":"Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)","commands":{"allow":[],"deny":["message"]}},"deny-confirm":{"identifier":"deny-confirm","description":"Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)","commands":{"allow":[],"deny":["message"]}},"deny-message":{"identifier":"deny-message","description":"Denies the message command without any pre-configured scope.","commands":{"allow":[],"deny":["message"]}},"deny-open":{"identifier":"deny-open","description":"Denies the open command without any pre-configured scope.","commands":{"allow":[],"deny":["open"]}},"deny-save":{"identifier":"deny-save","description":"Denies the save command without any pre-configured scope.","commands":{"allow":[],"deny":["save"]}}},"permission_sets":{},"global_scope_schema":null},"notification":{"default_permission":{"identifier":"default","description":"This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n","permissions":["allow-is-permission-granted","allow-request-permission","allow-notify","allow-register-action-types","allow-register-listener","allow-remove-listener","allow-cancel","allow-get-pending","allow-remove-active","allow-get-active","allow-check-permissions","allow-show","allow-batch","allow-list-channels","allow-delete-channel","allow-create-channel","allow-permission-state"]},"permissions":{"allow-batch":{"identifier":"allow-batch","description":"Enables the batch command without any pre-configured scope.","commands":{"allow":["batch"],"deny":[]}},"allow-cancel":{"identifier":"allow-cancel","description":"Enables the cancel command without any pre-configured scope.","commands":{"allow":["cancel"],"deny":[]}},"allow-check-permissions":{"identifier":"allow-check-permissions","description":"Enables the check_permissions command without any pre-configured scope.","commands":{"allow":["check_permissions"],"deny":[]}},"allow-create-channel":{"identifier":"allow-create-channel","description":"Enables the create_channel command without any pre-configured scope.","commands":{"allow":["create_channel"],"deny":[]}},"allow-delete-channel":{"identifier":"allow-delete-channel","description":"Enables the delete_channel command without any pre-configured scope.","commands":{"allow":["delete_channel"],"deny":[]}},"allow-get-active":{"identifier":"allow-get-active","description":"Enables the get_active command without any pre-configured scope.","commands":{"allow":["get_active"],"deny":[]}},"allow-get-pending":{"identifier":"allow-get-pending","description":"Enables the get_pending command without any pre-configured scope.","commands":{"allow":["get_pending"],"deny":[]}},"allow-is-permission-granted":{"identifier":"allow-is-permission-granted","description":"Enables the is_permission_granted command without any pre-configured scope.","commands":{"allow":["is_permission_granted"],"deny":[]}},"allow-list-channels":{"identifier":"allow-list-channels","description":"Enables the list_channels command without any pre-configured scope.","commands":{"allow":["list_channels"],"deny":[]}},"allow-notify":{"identifier":"allow-notify","description":"Enables the notify command without any pre-configured scope.","commands":{"allow":["notify"],"deny":[]}},"allow-permission-state":{"identifier":"allow-permission-state","description":"Enables the permission_state command without any pre-configured scope.","commands":{"allow":["permission_state"],"deny":[]}},"allow-register-action-types":{"identifier":"allow-register-action-types","description":"Enables the register_action_types command without any pre-configured scope.","commands":{"allow":["register_action_types"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-active":{"identifier":"allow-remove-active","description":"Enables the remove_active command without any pre-configured scope.","commands":{"allow":["remove_active"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-request-permission":{"identifier":"allow-request-permission","description":"Enables the request_permission command without any pre-configured scope.","commands":{"allow":["request_permission"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"deny-batch":{"identifier":"deny-batch","description":"Denies the batch command without any pre-configured scope.","commands":{"allow":[],"deny":["batch"]}},"deny-cancel":{"identifier":"deny-cancel","description":"Denies the cancel command without any pre-configured scope.","commands":{"allow":[],"deny":["cancel"]}},"deny-check-permissions":{"identifier":"deny-check-permissions","description":"Denies the check_permissions command without any pre-configured scope.","commands":{"allow":[],"deny":["check_permissions"]}},"deny-create-channel":{"identifier":"deny-create-channel","description":"Denies the create_channel command without any pre-configured scope.","commands":{"allow":[],"deny":["create_channel"]}},"deny-delete-channel":{"identifier":"deny-delete-channel","description":"Denies the delete_channel command without any pre-configured scope.","commands":{"allow":[],"deny":["delete_channel"]}},"deny-get-active":{"identifier":"deny-get-active","description":"Denies the get_active command without any pre-configured scope.","commands":{"allow":[],"deny":["get_active"]}},"deny-get-pending":{"identifier":"deny-get-pending","description":"Denies the get_pending command without any pre-configured scope.","commands":{"allow":[],"deny":["get_pending"]}},"deny-is-permission-granted":{"identifier":"deny-is-permission-granted","description":"Denies the is_permission_granted command without any pre-configured scope.","commands":{"allow":[],"deny":["is_permission_granted"]}},"deny-list-channels":{"identifier":"deny-list-channels","description":"Denies the list_channels command without any pre-configured scope.","commands":{"allow":[],"deny":["list_channels"]}},"deny-notify":{"identifier":"deny-notify","description":"Denies the notify command without any pre-configured scope.","commands":{"allow":[],"deny":["notify"]}},"deny-permission-state":{"identifier":"deny-permission-state","description":"Denies the permission_state command without any pre-configured scope.","commands":{"allow":[],"deny":["permission_state"]}},"deny-register-action-types":{"identifier":"deny-register-action-types","description":"Denies the register_action_types command without any pre-configured scope.","commands":{"allow":[],"deny":["register_action_types"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-active":{"identifier":"deny-remove-active","description":"Denies the remove_active command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_active"]}},"deny-remove-listener":{"identifier":"deny-remove-listener","description":"Denies the remove_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_listener"]}},"deny-request-permission":{"identifier":"deny-request-permission","description":"Denies the request_permission command without any pre-configured scope.","commands":{"allow":[],"deny":["request_permission"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}}},"permission_sets":{},"global_scope_schema":null},"opener":{"default_permission":{"identifier":"default","description":"This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer","permissions":["allow-open-url","allow-reveal-item-in-dir","allow-default-urls"]},"permissions":{"allow-default-urls":{"identifier":"allow-default-urls","description":"This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application.","commands":{"allow":[],"deny":[]},"scope":{"allow":[{"url":"mailto:*"},{"url":"tel:*"},{"url":"http://*"},{"url":"https://*"}]}},"allow-open-path":{"identifier":"allow-open-path","description":"Enables the open_path command without any pre-configured scope.","commands":{"allow":["open_path"],"deny":[]}},"allow-open-url":{"identifier":"allow-open-url","description":"Enables the open_url command without any pre-configured scope.","commands":{"allow":["open_url"],"deny":[]}},"allow-reveal-item-in-dir":{"identifier":"allow-reveal-item-in-dir","description":"Enables the reveal_item_in_dir command without any pre-configured scope.","commands":{"allow":["reveal_item_in_dir"],"deny":[]}},"deny-open-path":{"identifier":"deny-open-path","description":"Denies the open_path command without any pre-configured scope.","commands":{"allow":[],"deny":["open_path"]}},"deny-open-url":{"identifier":"deny-open-url","description":"Denies the open_url command without any pre-configured scope.","commands":{"allow":[],"deny":["open_url"]}},"deny-reveal-item-in-dir":{"identifier":"deny-reveal-item-in-dir","description":"Denies the reveal_item_in_dir command without any pre-configured scope.","commands":{"allow":[],"deny":["reveal_item_in_dir"]}}},"permission_sets":{},"global_scope_schema":{"$schema":"http://json-schema.org/draft-07/schema#","anyOf":[{"properties":{"app":{"allOf":[{"$ref":"#/definitions/Application"}],"description":"An application to open this url with, for example: firefox."},"url":{"description":"A URL that can be opened by the webview when using the Opener APIs.\n\nWildcards can be used following the UNIX glob pattern.\n\nExamples:\n\n- \"https://*\" : allows all HTTPS origin\n\n- \"https://*.github.com/tauri-apps/tauri\": allows any subdomain of \"github.com\" with the \"tauri-apps/api\" path\n\n- \"https://myapi.service.com/users/*\": allows access to any URLs that begins with \"https://myapi.service.com/users/\"","type":"string"}},"required":["url"],"type":"object"},{"properties":{"app":{"allOf":[{"$ref":"#/definitions/Application"}],"description":"An application to open this path with, for example: xdg-open."},"path":{"description":"A path that can be opened by the webview when using the Opener APIs.\n\nThe pattern can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$APP`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.","type":"string"}},"required":["path"],"type":"object"}],"definitions":{"Application":{"anyOf":[{"description":"Open in default application.","type":"null"},{"description":"If true, allow open with any application.","type":"boolean"},{"description":"Allow specific application to open with.","type":"string"}],"description":"Opener scope application."}},"description":"Opener scope entry.","title":"OpenerScopeEntry"}},"updater":{"default_permission":{"identifier":"default","description":"This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n","permissions":["allow-check","allow-download","allow-install","allow-download-and-install"]},"permissions":{"allow-check":{"identifier":"allow-check","description":"Enables the check command without any pre-configured scope.","commands":{"allow":["check"],"deny":[]}},"allow-download":{"identifier":"allow-download","description":"Enables the download command without any pre-configured scope.","commands":{"allow":["download"],"deny":[]}},"allow-download-and-install":{"identifier":"allow-download-and-install","description":"Enables the download_and_install command without any pre-configured scope.","commands":{"allow":["download_and_install"],"deny":[]}},"allow-install":{"identifier":"allow-install","description":"Enables the install command without any pre-configured scope.","commands":{"allow":["install"],"deny":[]}},"deny-check":{"identifier":"deny-check","description":"Denies the check command without any pre-configured scope.","commands":{"allow":[],"deny":["check"]}},"deny-download":{"identifier":"deny-download","description":"Denies the download command without any pre-configured scope.","commands":{"allow":[],"deny":["download"]}},"deny-download-and-install":{"identifier":"deny-download-and-install","description":"Denies the download_and_install command without any pre-configured scope.","commands":{"allow":[],"deny":["download_and_install"]}},"deny-install":{"identifier":"deny-install","description":"Denies the install command without any pre-configured scope.","commands":{"allow":[],"deny":["install"]}}},"permission_sets":{},"global_scope_schema":null}} \ No newline at end of file diff --git a/apps/desktop/src-tauri/gen/schemas/capabilities.json b/apps/desktop/src-tauri/gen/schemas/capabilities.json index 6c30f61f..273fc67b 100644 --- a/apps/desktop/src-tauri/gen/schemas/capabilities.json +++ b/apps/desktop/src-tauri/gen/schemas/capabilities.json @@ -1 +1 @@ -{"desktop":{"identifier":"desktop","description":"Narrow capabilities for the OpenAIDE Desktop App","local":true,"windows":["main"],"permissions":["core:default","core:window:allow-close","core:window:allow-minimize","core:window:allow-set-background-color","core:window:allow-start-dragging","core:window:allow-toggle-maximize","core:webview:allow-set-webview-background-color","dialog:allow-open","notification:default","opener:allow-default-urls","opener:allow-open-url"]}} \ No newline at end of file +{"desktop":{"identifier":"desktop","description":"Narrow capabilities for the OpenAIDE Desktop App","local":true,"windows":["main"],"permissions":["core:default","core:window:allow-close","core:window:allow-minimize","core:window:allow-set-background-color","core:window:allow-set-focus","core:window:allow-show","core:window:allow-start-dragging","core:window:allow-toggle-maximize","core:window:allow-unminimize","core:webview:allow-set-webview-background-color","dialog:allow-open","notification:default","opener:allow-default-urls","opener:allow-open-url"]}} \ No newline at end of file diff --git a/apps/desktop/src-tauri/src/desktop_connection.rs b/apps/desktop/src-tauri/src/desktop_connection.rs index 53b7b2c5..2c6a102d 100644 --- a/apps/desktop/src-tauri/src/desktop_connection.rs +++ b/apps/desktop/src-tauri/src/desktop_connection.rs @@ -3,7 +3,8 @@ //! This computer's App Server starts with the Desktop and stays held until the //! Desktop quits, whichever computer is in use, so switching away never stops //! its work or its own Remote Devices. The window uses one App Server at a time: -//! this computer's, or a paired computer's through a loopback gateway. +//! this computer's, a paired computer's through a loopback gateway, or an SSH +//! Computer's through an `ssh` forward (ADR-0069). //! //! A switch connects the target while the current one stays in use and replaces //! it only once connected. The window then reloads into the new one under a new @@ -143,6 +144,21 @@ impl DesktopConnections { previous.and_then(|previous| previous.gateway) } + /// Records a new forward to the SSH Computer `id`; true when the window uses + /// it and must follow the replacement. The SSH forward carries no gateway. + pub(crate) fn replace_ssh(&self, id: &str, connection: LocalHttpConnection) -> bool { + let Ok(mut in_use) = self.in_use.lock() else { + return false; + }; + match in_use.as_mut() { + Some(in_use) if in_use.computer.as_deref() == Some(id) => { + in_use.connection = connection; + true + } + _ => false, + } + } + /// Records a new start of this computer's App Server; true when the window /// uses it and must follow the replacement. fn replace_local(&self, connection: LocalHttpConnection) -> bool { @@ -242,7 +258,11 @@ impl DesktopConnections { } } -pub(crate) fn emit_bootstrap_progress(app: &tauri::AppHandle, stage: &'static str, message: String) { +pub(crate) fn emit_bootstrap_progress( + app: &tauri::AppHandle, + stage: &'static str, + message: String, +) { let _ = app.emit( "desktop-bootstrap-progress", DesktopBootstrapProgress { message, stage }, @@ -301,11 +321,24 @@ pub(crate) async fn desktop_bootstrap( let operation_id = format!("desktop-local-start-{}", uuid::Uuid::new_v4()); let _ = ensure_local(&background, None, operation_id).await; }); - emit_bootstrap_progress(&app, "remote", format!("Connecting to {}", computer.name)); - let (connection, gateway) = crate::desktop_remote::connect(&app, computer) - .await - .map_err(|error| error.message)?; - connections.start_using(Some(computer.id.clone()), connection, Some(gateway)); + let (connection, gateway) = match &computer.ssh { + Some(destination) => ( + crate::desktop_ssh::connect(&app, computer, destination, true).await?, + None, + ), + None => { + emit_bootstrap_progress( + &app, + "remote", + format!("Connecting to {}", computer.name), + ); + let (connection, gateway) = crate::desktop_remote::connect(&app, computer) + .await + .map_err(|error| error.message)?; + (connection, Some(gateway)) + } + }; + connections.start_using(Some(computer.id.clone()), connection, gateway); } None => { let connection = ensure_local(&app, Some(app.clone()), operation_id).await?; @@ -401,9 +434,15 @@ pub(crate) async fn ensure_local( pub(crate) async fn desktop_ensure_app_server( app: tauri::AppHandle, ) -> Result, String> { + // An SSH Computer's forward and App Server are this Desktop's to restore, as + // its own are; a paired computer's gateway reopens through its own retries. + let ssh = crate::desktop_ssh::ensure(&app).await; let before = app.state::().local(); let operation_id = format!("desktop-ensure-app-server-{}", uuid::Uuid::new_v4()); let connection = ensure_local(&app, None, operation_id).await?; + if let Some(ssh) = ssh { + return ssh; + } Ok((before.as_ref() != Some(&connection)).then_some(connection)) } diff --git a/apps/desktop/src-tauri/src/desktop_connection_tests.rs b/apps/desktop/src-tauri/src/desktop_connection_tests.rs index a20ed4a7..55c9fffa 100644 --- a/apps/desktop/src-tauri/src/desktop_connection_tests.rs +++ b/apps/desktop/src-tauri/src/desktop_connection_tests.rs @@ -157,3 +157,17 @@ fn only_this_computers_app_server_replacement_reaches_the_window() { assert_eq!(remote.in_use().unwrap().0, connection(2)); assert_eq!(remote.local(), Some(connection(4))); } + +#[test] +fn a_new_ssh_forward_is_followed_only_while_that_computer_is_in_use() { + let connections = DesktopConnections::default(); + connections.start_using(Some("ssh-devbox".to_string()), connection(2), None); + + assert!(!connections.replace_ssh("ssh-other", connection(3))); + assert!(connections.replace_ssh("ssh-devbox", connection(4))); + assert_eq!(connections.in_use().unwrap().0, connection(4)); + + let connections = on_this_computer(); + assert!(!connections.replace_ssh("ssh-devbox", connection(5))); + assert_eq!(connections.in_use().unwrap().0, connection(1)); +} diff --git a/apps/desktop/src-tauri/src/desktop_quit.rs b/apps/desktop/src-tauri/src/desktop_quit.rs new file mode 100644 index 00000000..c43a9960 --- /dev/null +++ b/apps/desktop/src-tauri/src/desktop_quit.rs @@ -0,0 +1,133 @@ +//! Native Quit: the open-Terminal warning and the fallback that guarantees exit. +//! +//! The WebView owns the graceful path (detach, close the session, end the App Server hold); +//! the fallback ends the process natively when that path cannot finish in time. Quitting a +//! Desktop that owns its App Server stops it, which closes every Terminal (ADR-0071), so a +//! Quit with Terminals open asks first. The question comes before the fallback starts, so +//! the user's answer never spends the exit budget. The count is pushed by the WebView as its +//! Terminal list changes, so asking needs no request to anything that might be hung. + +use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; +use std::thread; +use std::time::Duration; + +use tauri::{Emitter, Manager}; + +/// The WebView's graceful Quit is bounded by 3 s to detach plus 2 s to close its session. +const QUIT_FALLBACK: Duration = Duration::from_secs(5); + +#[derive(Default)] +pub(crate) struct DesktopQuitState { + // Native close can arrive before the WebView has registered its command + // listener. The WebView consumes this latch after crossing that boundary. + pending: AtomicBool, + /// Open Terminals of this Desktop's App Server, as last reported by the WebView. + open_terminals: AtomicU32, + /// Keeps a second Quit from stacking another question while one is shown. + confirming: AtomicBool, +} + +#[tauri::command] +pub(crate) fn desktop_take_pending_quit(state: tauri::State<'_, DesktopQuitState>) -> bool { + state.pending.swap(false, Ordering::AcqRel) +} + +#[tauri::command] +pub(crate) fn desktop_set_open_terminals(state: tauri::State<'_, DesktopQuitState>, count: u32) { + state.open_terminals.store(count, Ordering::Release); +} + +/// The question to ask before Quit, if quitting would close Terminals. +pub(crate) fn quit_warning(open_terminals: u32, owns_app_server: bool) -> Option { + // A paired or SSH Computer's App Server outlives this Desktop, and its Terminals with it. + // TODO(terminals): a Remote Device still connected also keeps this App Server running, so + // the warning overstates; App Server would have to report whether this is its last client. + if !owns_app_server || open_terminals == 0 { + return None; + } + Some(match open_terminals { + 1 => "1 terminal will close.".to_string(), + count => format!("{count} terminals will close."), + }) +} + +/// Quit from the native menu, or the window's close on Windows. +pub(crate) fn request_quit(app: &tauri::AppHandle) { + let state = app.state::(); + if state.pending.load(Ordering::Acquire) { + // A repeated Quit is the WebView's force-exit request. + let _ = app.emit("desktop-command", "quit"); + return; + } + let warning = quit_warning( + state.open_terminals.load(Ordering::Acquire), + // This computer's App Server is in use unless the window moved to another. + !matches!( + app.state::() + .computer_in_use(), + Some(Some(_)) + ), + ); + let Some(warning) = warning else { + start_quit(app); + return; + }; + if state.confirming.swap(true, Ordering::AcqRel) { + return; + } + let count = state.open_terminals.load(Ordering::Acquire); + desktop_log!("desktop_quit_confirm_shown open_terminals={count}"); + use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind}; + let handle = app.clone(); + app.dialog() + .message(warning) + .title("Quit OpenAIDE?") + .kind(MessageDialogKind::Warning) + .buttons(MessageDialogButtons::OkCancelCustom( + "Quit".to_string(), + "Cancel".to_string(), + )) + .show(move |quit| { + handle + .state::() + .confirming + .store(false, Ordering::Release); + desktop_log!( + "desktop_quit_confirm_answered outcome={}", + if quit { "quit" } else { "cancelled" } + ); + if quit { + start_quit(&handle); + } + }); +} + +/// Hands Quit to the WebView and ends the process natively if it does not finish in time. +fn start_quit(app: &tauri::AppHandle) { + if app + .state::() + .pending + .swap(true, Ordering::AcqRel) + { + return; + } + let _ = app.emit("desktop-command", "quit"); + let app = app.clone(); + thread::spawn(move || { + thread::sleep(QUIT_FALLBACK); + if app + .state::() + .pending + .swap(false, Ordering::AcqRel) + { + // Startup may not have produced a frontend session that can detach. Never + // leave an unclosable native app behind. + desktop_log!("desktop_quit_fallback_exit"); + app.exit(0); + } + }); +} + +#[cfg(test)] +#[path = "desktop_quit_tests.rs"] +mod tests; diff --git a/apps/desktop/src-tauri/src/desktop_quit_tests.rs b/apps/desktop/src-tauri/src/desktop_quit_tests.rs new file mode 100644 index 00000000..78dc0dce --- /dev/null +++ b/apps/desktop/src-tauri/src/desktop_quit_tests.rs @@ -0,0 +1,20 @@ +use super::quit_warning; + +#[test] +fn quitting_with_terminals_open_on_this_computers_app_server_asks_first() { + assert_eq!( + quit_warning(1, true).as_deref(), + Some("1 terminal will close.") + ); + assert_eq!( + quit_warning(3, true).as_deref(), + Some("3 terminals will close.") + ); +} + +#[test] +fn quitting_without_terminals_or_from_another_computer_does_not_ask() { + assert_eq!(quit_warning(0, true), None); + // The other computer's App Server keeps running, and its Terminals with it. + assert_eq!(quit_warning(2, false), None); +} diff --git a/apps/desktop/src-tauri/src/desktop_remote.rs b/apps/desktop/src-tauri/src/desktop_remote.rs index ffad0629..246f0e67 100644 --- a/apps/desktop/src-tauri/src/desktop_remote.rs +++ b/apps/desktop/src-tauri/src/desktop_remote.rs @@ -23,16 +23,20 @@ use crate::desktop_connection::{ }; use crate::desktop_runtime::{DesktopBootstrapPreferences, write_bootstrap_preferences}; -/// A computer whose App Server trusts this Desktop: its public key and the name -/// it gave itself. Neither is a credential; the device key is stored apart. +/// A computer whose App Server this Desktop may use instead of its own. A +/// paired computer is known by its public key and the name it gave itself; an +/// SSH Computer (ADR-0069) by its `ssh` destination. None is a credential; the +/// device key is stored apart. #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub(crate) struct RemoteComputer { pub(crate) id: String, pub(crate) name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) ssh: Option, } impl DesktopBootstrapPreferences { - /// The paired computer in use instead of this one. + /// The paired or SSH Computer in use instead of this one. pub(crate) fn remote(&self) -> Option<&RemoteComputer> { let id = self.remote_computer.as_ref()?; self.remote_computers @@ -42,7 +46,7 @@ impl DesktopBootstrapPreferences { /// Remembers a computer, replacing an earlier pairing with it. Using it is a /// switch of its own, saved once the computer answers. - fn remember(&mut self, computer: RemoteComputer) { + pub(crate) fn remember(&mut self, computer: RemoteComputer) { self.remote_computers .retain(|known| known.id != computer.id); self.remote_computers.push(computer); @@ -71,6 +75,8 @@ pub(crate) struct RemoteComputerSummary { id: String, name: String, current: bool, + #[serde(skip_serializing_if = "Option::is_none")] + ssh: Option, } /// This Desktop's endpoint, bound on first use and kept for the process: a @@ -115,10 +121,13 @@ pub(crate) async fn connect( app: &tauri::AppHandle, computer: &RemoteComputer, ) -> Result<(LocalHttpConnection, remote::Gateway), ConnectError> { - let server = computer.id.parse::().map_err(|_| ConnectError { - class: "pairing_damaged", - message: "The saved pairing is damaged. Pair with the computer again.".to_string(), - })?; + let server = computer + .id + .parse::() + .map_err(|_| ConnectError { + class: "pairing_damaged", + message: "The saved pairing is damaged. Pair with the computer again.".to_string(), + })?; let this_computer = |message: String| ConnectError { class: "endpoint", message, @@ -129,13 +138,10 @@ pub(crate) async fn connect( .await .map_err(this_computer)?; let client = Arc::new(remote::RemoteClient::new(endpoint, server, logger())); - client - .connection() - .await - .map_err(|failure| ConnectError { - class: failure.class(), - message: message(failure, &computer.name), - })?; + client.connection().await.map_err(|failure| ConnectError { + class: failure.class(), + message: message(failure, &computer.name), + })?; let reporter = app.clone(); let name = computer.name.clone(); // Each connection says once that retrying cannot help, so a later switch to @@ -147,7 +153,9 @@ pub(crate) async fn connect( } }) .await - .map_err(|_| this_computer("OpenAIDE could not start its connection on this computer.".to_string()))?; + .map_err(|_| { + this_computer("OpenAIDE could not start its connection on this computer.".to_string()) + })?; let connection = LocalHttpConnection { kind: "localHttp".to_string(), endpoint_url: format!("http://{}/probe", gateway.address), @@ -170,10 +178,14 @@ pub(crate) async fn switch( state .preferences .lock() - .map_err(|_| SwitchError::Failed("Desktop preferences are unavailable.".to_string()))? + .map_err(|_| { + SwitchError::Failed("Desktop preferences are unavailable.".to_string()) + })? .paired(id) .cloned() - .ok_or_else(|| SwitchError::Failed("This computer is no longer paired.".to_string()))?, + .ok_or_else(|| { + SwitchError::Failed("This computer is no longer paired.".to_string()) + })?, ), None => None, }; @@ -184,9 +196,20 @@ pub(crate) async fn switch( operation_id, || async { match &computer { - Some(computer) => connect(app, computer) - .await - .map(|(connection, gateway)| (connection, Some(gateway))), + Some(computer) => match &computer.ssh { + Some(destination) => { + crate::desktop_ssh::connect(app, computer, destination, false) + .await + .map(|connection| (connection, None)) + .map_err(|message| ConnectError { + class: "ssh_connect", + message, + }) + } + None => connect(app, computer) + .await + .map(|(connection, gateway)| (connection, Some(gateway))), + }, None => ensure_local(app, None, operation_id.to_string()) .await .map(|connection| (connection, None)) @@ -224,7 +247,9 @@ fn operation_id_or_new(candidate: Option) -> String { .filter(|id| { !id.is_empty() && id.len() <= 80 - && id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') + && id + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') }) .unwrap_or_else(|| format!("desktop-computer-switch-{}", uuid::Uuid::new_v4())) } @@ -244,6 +269,7 @@ pub(crate) fn desktop_remote_snapshot( id: computer.id.clone(), name: computer.name.clone(), current: preferences.remote_computer.as_ref() == Some(&computer.id), + ssh: computer.ssh.clone(), }) .collect()) } @@ -296,6 +322,7 @@ pub(crate) async fn desktop_remote_pair( } else { paired.name }, + ssh: None, }); write_bootstrap_preferences(&state.preferences_path, &preferences)?; } @@ -312,7 +339,16 @@ pub(crate) async fn desktop_remote_use( id: Option, operation_id: Option, ) -> Result<&'static str, String> { - outcome_name(switch(&app, id, &operation_id_or_new(operation_id)).await) + use_computer(&app, id, operation_id).await +} + +/// [`desktop_remote_use`] for the other commands that end in a switch. +pub(crate) async fn use_computer( + app: &tauri::AppHandle, + id: Option, + operation_id: Option, +) -> Result<&'static str, String> { + outcome_name(switch(app, id, &operation_id_or_new(operation_id)).await) } /// Ends the switch attempt in progress; the window stays where it is. @@ -411,7 +447,7 @@ fn message(failure: remote::Failure, name: &str) -> String { } /// Prints the client's metadata-only events in this shell's log format. -fn logger() -> remote::Logger { +pub(crate) fn logger() -> remote::Logger { Arc::new(|event, fields| { let fields = fields .as_object() diff --git a/apps/desktop/src-tauri/src/desktop_remote_tests.rs b/apps/desktop/src-tauri/src/desktop_remote_tests.rs index 00e83f88..151a4e80 100644 --- a/apps/desktop/src-tauri/src/desktop_remote_tests.rs +++ b/apps/desktop/src-tauri/src/desktop_remote_tests.rs @@ -4,6 +4,7 @@ fn computer(id: &str, name: &str) -> RemoteComputer { RemoteComputer { id: id.to_string(), name: name.to_string(), + ssh: None, } } diff --git a/apps/desktop/src-tauri/src/desktop_runtime.rs b/apps/desktop/src-tauri/src/desktop_runtime.rs index 0c38a6fc..48fb3026 100644 --- a/apps/desktop/src-tauri/src/desktop_runtime.rs +++ b/apps/desktop/src-tauri/src/desktop_runtime.rs @@ -102,6 +102,10 @@ pub(crate) struct DesktopBootstrapPreferences { /// The paired computer in use instead of this one, by id. #[serde(default, skip_serializing_if = "Option::is_none")] pub(crate) remote_computer: Option, + /// The SSH Computer whose older App Server the user agreed to replace on + /// the next start (ADR-0069). Cleared by that start. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) ssh_replace: Option, } pub(crate) fn read_bootstrap_preferences(path: &Path) -> DesktopBootstrapPreferences { diff --git a/apps/desktop/src-tauri/src/desktop_ssh.rs b/apps/desktop/src-tauri/src/desktop_ssh.rs new file mode 100644 index 00000000..b0c2ce78 --- /dev/null +++ b/apps/desktop/src-tauri/src/desktop_ssh.rs @@ -0,0 +1,503 @@ +//! SSH Computers in the Desktop (ADR-0069). +//! +//! An SSH Computer is one more choice of where agents work, kept beside paired +//! computers and switched through the same restart. Starting with one installs +//! this Desktop's App Server there through the system `ssh`, has its keeper hold +//! it, and forwards its endpoint to loopback; the WebView then talks to it like +//! to this computer's own App Server. This module owns the forward for the +//! process and what the WebView may ask about it: the last outcome, prompts, +//! updates, and stopping. + +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; +use std::time::Instant; + +use openaide_remote_client::{LaunchError, Launched, SshLauncher, Stage, StartMode, StopOutcome}; +use serde::Serialize; +use tauri::{Emitter, Manager}; + +use crate::DesktopState; +use crate::desktop_app_server_launch::{LocalHttpConnection, app_server_endpoint_reachable}; +use crate::desktop_connection::DesktopConnections; +use crate::desktop_remote::RemoteComputer; +use crate::desktop_runtime::write_bootstrap_preferences; +use crate::desktop_ssh_askpass::{AskpassBroker, SshPrompt}; +use crate::desktop_ssh_release::ReleaseSource; +use crate::desktop_ssh_transport::{SshForward, SystemSsh, is_destination}; + +pub(crate) struct DesktopSshState { + askpass: Mutex>>, + forward: Mutex>, + status: Mutex>, + failure: Mutex>, + cache_root: PathBuf, +} + +/// What the App Shell knows about the SSH Computer it uses. +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct SshStatus { + computer_id: String, + running_version: String, + /// The App Server there is older but compatible; an update may be offered. + update_available: bool, + /// Logind would end the App Server when the user's last session closes. + suggest_linger: bool, +} + +/// Why the last start did not reach the SSH Computer's App Server. +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct SshFailure { + computer_id: String, + /// `updateRequired` when only a confirmed update can help. + kind: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + running_version: Option, + stage: &'static str, + error_class: String, +} + +impl DesktopSshState { + pub(crate) fn new(cache_root: PathBuf) -> Self { + Self { + askpass: Mutex::new(None), + forward: Mutex::new(None), + status: Mutex::new(None), + failure: Mutex::new(None), + cache_root, + } + } + + fn askpass(&self, app: &tauri::AppHandle) -> Result, String> { + let mut broker = self + .askpass + .lock() + .map_err(|_| "SSH state is unavailable.".to_string())?; + if let Some(broker) = broker.as_ref() { + return Ok(broker.clone()); + } + let started = AskpassBroker::start(app.clone()) + .map_err(|_| "OpenAIDE could not prepare its SSH sign-in prompts.".to_string())?; + *broker = Some(started.clone()); + Ok(started) + } +} + +/// Starts or attaches to the App Server on `computer` and forwards it. The +/// startup screen shows each stage when `show_progress` is set. +pub(crate) async fn connect( + app: &tauri::AppHandle, + computer: &RemoteComputer, + destination: &str, + show_progress: bool, +) -> Result { + let ssh_state = app.state::(); + let mode = take_confirmed_update(app, &computer.id)?; + let broker = ssh_state.askpass(app)?; + let source = release_source(app, &ssh_state)?; + let operation_id = format!("desktop-ssh-connect-{}", uuid::Uuid::new_v4()); + let name = computer.name.clone(); + let progress_app = app.clone(); + let launcher = SshLauncher::new( + source.release.clone(), + crate::desktop_remote::logger(), + Box::new(move |stage| { + if show_progress { + crate::desktop_connection::emit_bootstrap_progress( + &progress_app, + "ssh", + stage_message(stage, &name), + ); + } + }), + ); + let environment = broker.environment(&computer.name); + let destination = destination.to_string(); + let started = Instant::now(); + desktop_log!("desktop_ssh_connect_started operation_id={operation_id}"); + let launched = tauri::async_runtime::spawn_blocking(move || { + let mut transport = SystemSsh::new(&destination, environment); + let mut fetch = || source.open(); + launcher.launch(&mut transport, mode, &mut fetch, &operation_id) + }) + .await + .map_err(|_| "The SSH connection task failed.".to_string())?; + desktop_log!( + "desktop_ssh_connect_completed outcome={} duration_ms={}", + match &launched { + Ok(Launched::Ready(_)) => "ready", + Ok(Launched::UpdateRequired { .. }) => "update_required", + Err(_) => "failure", + }, + started.elapsed().as_millis() + ); + let failure = |kind, running_version, stage: Stage, error_class: String| SshFailure { + computer_id: computer.id.clone(), + kind, + running_version, + stage: stage.as_str(), + error_class, + }; + match launched { + Ok(Launched::Ready(ready)) => { + set(&ssh_state.failure, None); + set( + &ssh_state.status, + Some(SshStatus { + computer_id: computer.id.clone(), + running_version: ready.app_version.clone(), + update_available: ready.update_available, + suggest_linger: ready.suggest_linger, + }), + ); + // Replacing the forward ends the previous `ssh`. + set(&ssh_state.forward, Some(ready.forward)); + Ok(LocalHttpConnection { + kind: "localHttp".to_string(), + endpoint_url: ready.endpoint_url, + auth_token: ready.auth_token, + }) + } + Ok(Launched::UpdateRequired { running_version }) => { + set( + &ssh_state.failure, + Some(failure( + "updateRequired", + Some(running_version.clone()), + Stage::Start, + "update_required".to_string(), + )), + ); + Err(format!( + "{} runs an older OpenAIDE ({running_version}) that this version cannot use. Update it to continue.", + computer.name + )) + } + Err(error) => { + let running_version = match &error { + LaunchError::ServerNewer { running_version } => Some(running_version.clone()), + _ => None, + }; + set( + &ssh_state.failure, + Some(failure( + "failed", + running_version, + error.stage(), + error.class().to_string(), + )), + ); + Err(message(&error, &computer.name)) + } + } +} + +/// Brings the SSH Computer's App Server back after sleep or after its keeper +/// let it go. `None` when no SSH Computer is in use. +pub(crate) async fn ensure( + app: &tauri::AppHandle, +) -> Option, String>> { + let connections = app.state::(); + let id = connections.computer_in_use()??; + let computer = app + .state::() + .preferences + .lock() + .ok()? + .remote_computers + .iter() + .find(|computer| computer.id == id) + .cloned()?; + let destination = computer.ssh.clone()?; + Some( + async { + if let Some((connection, _)) = connections.in_use() + && app_server_endpoint_reachable(&connection).await + { + return Ok(None); + } + let started = Instant::now(); + desktop_log!("desktop_ssh_ensure_started reason_code=endpoint_unreachable"); + let connected = connect(app, &computer, &destination, false).await; + let connection = match connected { + Ok(connection) => connection, + Err(message) => { + desktop_log!( + "desktop_ssh_ensure_completed outcome=failure duration_ms={}", + started.elapsed().as_millis() + ); + return Err(message); + } + }; + // A switch away while connecting leaves the window where it went. + let window_follows = connections.replace_ssh(&computer.id, connection.clone()); + if window_follows { + let _ = app.emit("desktop-app-server-replaced", &connection); + } + desktop_log!( + "desktop_ssh_ensure_completed outcome=success window_follows={window_follows} duration_ms={}", + started.elapsed().as_millis() + ); + Ok(window_follows.then_some(connection)) + } + .await, + ) +} + +/// An update the user confirmed applies to the next start with that computer only. +fn take_confirmed_update(app: &tauri::AppHandle, id: &str) -> Result { + let state = app.state::(); + let mut preferences = state + .preferences + .lock() + .map_err(|_| "desktop bootstrap preferences unavailable")?; + if preferences.ssh_replace.as_deref() != Some(id) { + return Ok(StartMode::Keep); + } + preferences.ssh_replace = None; + write_bootstrap_preferences(&state.preferences_path, &preferences)?; + desktop_log!("desktop_ssh_update_confirmed_applied"); + Ok(StartMode::Replace) +} + +fn release_source( + app: &tauri::AppHandle, + ssh_state: &DesktopSshState, +) -> Result { + let version = app.package_info().version.to_string(); + let wsl_binary = app.state::().wsl_resource_binary.clone(); + crate::desktop_ssh_release::resolve(&version, &wsl_binary, &ssh_state.cache_root).map_err( + |class| { + desktop_log!("desktop_ssh_release_unavailable error_class={class}"); + "This build of OpenAIDE cannot install its App Server on SSH computers.".to_string() + }, + ) +} + +#[tauri::command] +pub(crate) fn desktop_ssh_hosts(app: tauri::AppHandle) -> Vec { + app.path() + .home_dir() + .map(|home| crate::desktop_ssh_config::host_aliases(&home.join(".ssh"))) + .unwrap_or_default() +} + +/// Remembers an SSH Computer by its `ssh` destination, then switches to it like +/// to a paired computer: `switched` (the window reloads), `alreadyInUse`, or +/// `cancelled`. A computer that cannot be reached stays saved and unused. +#[tauri::command] +pub(crate) async fn desktop_ssh_add( + app: tauri::AppHandle, + state: tauri::State<'_, DesktopState>, + destination: String, + name: Option, + operation_id: Option, +) -> Result<&'static str, String> { + let destination = destination.trim().to_string(); + if !is_destination(&destination) { + return Err("Enter an SSH host alias or user@host.".to_string()); + } + let name = name + .map(|name| openaide_remote_client::clamp_label(&name)) + .filter(|name| !name.is_empty()) + .unwrap_or_else(|| openaide_remote_client::clamp_label(&destination)); + let id = { + let mut preferences = state + .preferences + .lock() + .map_err(|_| "desktop bootstrap preferences unavailable")?; + let id = preferences + .remote_computers + .iter() + .find(|computer| computer.ssh.as_deref() == Some(destination.as_str())) + .map(|computer| computer.id.clone()) + .unwrap_or_else(|| format!("ssh-{}", uuid::Uuid::new_v4().simple())); + preferences.remember(RemoteComputer { + id: id.clone(), + name, + ssh: Some(destination), + }); + write_bootstrap_preferences(&state.preferences_path, &preferences)?; + id + }; + desktop_log!("desktop_ssh_computer_added"); + crate::desktop_remote::use_computer(&app, Some(id), operation_id).await +} + +#[tauri::command] +pub(crate) fn desktop_ssh_status(state: tauri::State<'_, DesktopSshState>) -> Option { + state.status.lock().ok()?.clone() +} + +#[tauri::command] +pub(crate) fn desktop_ssh_failure(state: tauri::State<'_, DesktopSshState>) -> Option { + state.failure.lock().ok()?.clone() +} + +/// Replaces the App Server on the SSH Computer in use on the next start. The +/// WebView asks only after showing what the replacement interrupts. +// TODO(ssh-computers): reconnect in place through `DesktopConnections::switch` +// instead of restarting Desktop, once a switch can target the computer in use. +#[tauri::command] +pub(crate) fn desktop_ssh_update( + app: tauri::AppHandle, + state: tauri::State<'_, DesktopState>, +) -> Result<(), String> { + let mut preferences = state + .preferences + .lock() + .map_err(|_| "desktop bootstrap preferences unavailable")?; + let id = preferences + .remote() + .filter(|computer| computer.ssh.is_some()) + .map(|computer| computer.id.clone()) + .ok_or_else(|| "No SSH computer is in use.".to_string())?; + preferences.ssh_replace = Some(id); + write_bootstrap_preferences(&state.preferences_path, &preferences)?; + drop(preferences); + desktop_log!("desktop_ssh_update_confirmed"); + crate::restart_desktop(&app); + Ok(()) +} + +/// Ends the keeper on an SSH Computer. Its App Server then stops once no App +/// Shell uses it. +#[tauri::command] +pub(crate) async fn desktop_ssh_stop( + app: tauri::AppHandle, + state: tauri::State<'_, DesktopState>, + ssh_state: tauri::State<'_, DesktopSshState>, + id: String, +) -> Result<&'static str, String> { + let computer = state + .preferences + .lock() + .map_err(|_| "desktop bootstrap preferences unavailable")? + .remote_computers + .iter() + .find(|computer| computer.id == id) + .cloned() + .ok_or_else(|| "This computer is no longer saved.".to_string())?; + let destination = computer + .ssh + .clone() + .ok_or_else(|| "This computer is not an SSH computer.".to_string())?; + let environment = ssh_state.askpass(&app)?.environment(&computer.name); + let source = release_source(&app, &ssh_state)?; + let launcher = SshLauncher::new( + source.release, + crate::desktop_remote::logger(), + Box::new(|_| {}), + ); + let operation_id = format!("desktop-ssh-stop-{}", uuid::Uuid::new_v4()); + let stopped = tauri::async_runtime::spawn_blocking(move || { + launcher.stop( + &mut SystemSsh::new(&destination, environment), + &operation_id, + ) + }) + .await + .map_err(|_| "The SSH connection task failed.".to_string())?; + match stopped { + Ok(StopOutcome::Stopped) => Ok("stopped"), + Ok(StopOutcome::NotRunning) => Ok("notRunning"), + Ok(StopOutcome::StillRunning) => Ok("stillRunning"), + Err(error) => Err(message(&error, &computer.name)), + } +} + +#[tauri::command] +pub(crate) fn desktop_ssh_prompts( + app: tauri::AppHandle, + state: tauri::State<'_, DesktopSshState>, +) -> Vec { + state + .askpass(&app) + .map(|broker| broker.pending()) + .unwrap_or_default() +} + +#[tauri::command] +pub(crate) fn desktop_ssh_prompt_answer( + app: tauri::AppHandle, + state: tauri::State<'_, DesktopSshState>, + id: u64, + answer: Option, +) -> bool { + state + .askpass(&app) + .map(|broker| broker.answer(id, answer)) + .unwrap_or(false) +} + +fn set(slot: &Mutex>, value: Option) { + if let Ok(mut slot) = slot.lock() { + *slot = value; + } +} + +fn stage_message(stage: Stage, name: &str) -> String { + match stage { + Stage::Probe => format!("Connecting to {name}"), + Stage::Fetch => "Downloading the App Server".to_string(), + Stage::Upload => format!("Installing the App Server on {name}"), + Stage::Start => format!("Starting the App Server on {name}"), + Stage::Forward => format!("Opening the connection to {name}"), + } +} + +fn message(error: &LaunchError, name: &str) -> String { + match error { + LaunchError::UnsupportedPlatform => { + format!( + "{name} is not a Linux x64 computer. OpenAIDE runs on SSH computers with Linux x64 only." + ) + } + LaunchError::Fetch(_) => { + "OpenAIDE could not download its App Server. Check this computer's internet connection." + .to_string() + } + LaunchError::Install("checksum_tool_missing") => { + format!("{name} has no sha256sum, which OpenAIDE needs to verify its install.") + } + LaunchError::Install(_) => format!( + "OpenAIDE could not install its App Server on {name}. Check the free space in your home directory there." + ), + LaunchError::ServerNewer { running_version } => format!( + "{name} runs a newer OpenAIDE ({running_version}). Update OpenAIDE on this computer first." + ), + LaunchError::ServerVersionUnknown => format!( + "{name} runs an OpenAIDE whose version is unknown. Stop it there, then try again." + ), + LaunchError::UnreadableAnswer(_) => format!( + "{name} gave an unexpected answer. Its login shell may print text in non-interactive sessions." + ), + LaunchError::Keeper(class) => { + format!("OpenAIDE could not start on {name} ({class}).") + } + LaunchError::Transport { error, .. } => match error.class { + "ssh_missing" => { + "OpenAIDE needs the ssh command. Install OpenSSH, then try again.".to_string() + } + "auth_failed" => { + format!("{name} did not accept the sign-in. Check your SSH key or password.") + } + "host_key_rejected" => format!( + "{name}'s host key does not match the one this computer knows. Check known_hosts before connecting." + ), + "host_unknown" => format!("OpenAIDE could not find {name}. Check the host name."), + "ssh_config_invalid" => { + "Your SSH configuration has an error. Run ssh in a terminal to see it.".to_string() + } + "forward_failed" | "forward_timeout" | "local_port_unavailable" => format!( + "OpenAIDE could not open its connection to {name}. A LocalForward in your SSH configuration may be in the way." + ), + _ => format!("{name} is unreachable over SSH."), + }, + } +} + +#[cfg(test)] +#[path = "desktop_ssh_tests.rs"] +mod tests; diff --git a/apps/desktop/src-tauri/src/desktop_ssh_askpass.rs b/apps/desktop/src-tauri/src/desktop_ssh_askpass.rs new file mode 100644 index 00000000..46d83e09 --- /dev/null +++ b/apps/desktop/src-tauri/src/desktop_ssh_askpass.rs @@ -0,0 +1,279 @@ +//! Shows the system `ssh`'s questions in the Desktop (ADR-0069): passwords, +//! passphrases, one-time codes, and host key confirmations. +//! +//! Each `ssh` this shell starts names the Desktop's own executable as its +//! `SSH_ASKPASS` program. Started that way, the executable does not open a +//! window: it carries the one prompt over loopback to the running Desktop, +//! which asks the user and returns the answer. The listener accepts only +//! callers holding the token it handed to its `ssh` processes. Answers pass +//! through memory once and are never stored or logged. + +use std::collections::HashMap; +use std::io::{BufRead, BufReader, Read, Write}; +use std::net::{SocketAddr, TcpListener, TcpStream}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::mpsc; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use serde::{Deserialize, Serialize}; +use tauri::Emitter; + +const ADDRESS_ENV: &str = "OPENAIDE_SSH_ASKPASS_ADDRESS"; +const TOKEN_ENV: &str = "OPENAIDE_SSH_ASKPASS_TOKEN"; +const LABEL_ENV: &str = "OPENAIDE_SSH_ASKPASS_LABEL"; +pub(crate) const PROMPT_EVENT: &str = "desktop-ssh-prompt"; +/// `ssh` gives up on its own connection timeout well before a person answers, +/// so this only bounds a prompt nobody is looking at. +const ANSWER_TIMEOUT: Duration = Duration::from_secs(300); +const MAX_MESSAGE_BYTES: u64 = 16 * 1024; + +/// What the helper sends: its token, `ssh`'s prompt, and `SSH_ASKPASS_PROMPT`. +#[derive(Deserialize, Serialize)] +struct Request { + token: String, + prompt: String, + hint: Option, + label: Option, +} + +#[derive(Deserialize, Serialize)] +struct Reply { + answer: Option, +} + +/// One question as the WebView shows it. +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct SshPrompt { + id: u64, + /// The SSH Computer's name in this Desktop. + computer: String, + /// `ssh`'s own wording, such as a password request or a host key fingerprint. + text: String, + kind: PromptKind, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) enum PromptKind { + /// Typed and hidden: a password, passphrase, PIN, or code. + Secret, + /// Answered `yes` or `no`, such as trusting a new host key. + YesNo, + /// Only shown, such as a request to touch a security key. + Notice, +} + +impl PromptKind { + fn of(text: &str, hint: Option<&str>) -> Self { + match hint { + Some("none") => Self::Notice, + Some("confirm") => Self::YesNo, + _ if text.contains("(yes/no") => Self::YesNo, + _ => Self::Secret, + } + } + + fn as_str(self) -> &'static str { + match self { + Self::Secret => "secret", + Self::YesNo => "yes_no", + Self::Notice => "notice", + } + } +} + +/// A prompt shown in the App Shell and where its answer goes. +type PendingPrompt = (SshPrompt, mpsc::Sender>); + +/// The running Desktop's half: one loopback listener for the whole process. +pub(crate) struct AskpassBroker { + address: SocketAddr, + token: String, + next_id: AtomicU64, + pending: Mutex>, +} + +impl AskpassBroker { + pub(crate) fn start(app: tauri::AppHandle) -> std::io::Result> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let broker = Arc::new(Self { + address: listener.local_addr()?, + token: format!( + "{}{}", + uuid::Uuid::new_v4().simple(), + uuid::Uuid::new_v4().simple() + ), + next_id: AtomicU64::new(1), + pending: Mutex::new(HashMap::new()), + }); + let serving = broker.clone(); + std::thread::Builder::new() + .name("ssh-askpass".to_string()) + .spawn(move || { + for stream in listener.incoming().flatten() { + let broker = serving.clone(); + let app = app.clone(); + let _ = std::thread::Builder::new() + .name("ssh-askpass-prompt".to_string()) + .spawn(move || broker.serve(&app, stream)); + } + })?; + desktop_log!("desktop_ssh_askpass_started"); + Ok(broker) + } + + /// The environment that makes an `ssh` ask this Desktop. + pub(crate) fn environment(&self, computer: &str) -> Vec<(&'static str, String)> { + let program = std::env::current_exe() + .map(|path| path.to_string_lossy().into_owned()) + .unwrap_or_default(); + let mut environment = vec![ + ("SSH_ASKPASS", program), + // OpenSSH 8.4 and later use askpass even with a terminal attached. + ("SSH_ASKPASS_REQUIRE", "force".to_string()), + (ADDRESS_ENV, self.address.to_string()), + (TOKEN_ENV, self.token.clone()), + (LABEL_ENV, computer.to_string()), + ]; + // Older OpenSSH, including Windows 10's, uses askpass only with a display. + if std::env::var_os("DISPLAY").is_none() { + environment.push(("DISPLAY", "openaide:0".to_string())); + } + environment + } + + pub(crate) fn pending(&self) -> Vec { + let mut prompts: Vec = self + .pending + .lock() + .map(|pending| pending.values().map(|(prompt, _)| prompt.clone()).collect()) + .unwrap_or_default(); + prompts.sort_by_key(|prompt| prompt.id); + prompts + } + + /// Delivers the user's answer; `None` declines. + pub(crate) fn answer(&self, id: u64, answer: Option) -> bool { + let entry = self + .pending + .lock() + .ok() + .and_then(|mut pending| pending.remove(&id)); + match entry { + Some((_, reply)) => reply.send(answer).is_ok(), + None => false, + } + } + + fn serve(&self, app: &tauri::AppHandle, stream: TcpStream) { + let started = Instant::now(); + let Some(request) = read_message::(&stream) else { + desktop_log!("desktop_ssh_prompt_refused reason=unreadable"); + return; + }; + if !same_token(&request.token, &self.token) { + desktop_log!("desktop_ssh_prompt_refused reason=token"); + return; + } + let id = self.next_id.fetch_add(1, Ordering::Relaxed); + let kind = PromptKind::of(&request.prompt, request.hint.as_deref()); + let prompt = SshPrompt { + id, + computer: request.label.unwrap_or_default(), + text: request.prompt, + kind, + }; + desktop_log!( + "desktop_ssh_prompt_started prompt_id={id} kind={}", + kind.as_str() + ); + let (sender, receiver) = mpsc::channel(); + if kind != PromptKind::Notice + && let Ok(mut pending) = self.pending.lock() + { + pending.insert(id, (prompt.clone(), sender)); + } + let _ = app.emit(PROMPT_EVENT, &prompt); + // A notice needs no answer: `ssh` ends the helper when it is done. + let (answer, outcome) = if kind == PromptKind::Notice { + (None, "shown") + } else { + match receiver.recv_timeout(ANSWER_TIMEOUT) { + Ok(Some(answer)) => (Some(answer), "answered"), + Ok(None) => (None, "declined"), + Err(_) => { + if let Ok(mut pending) = self.pending.lock() { + pending.remove(&id); + } + (None, "timed_out") + } + } + }; + let _ = write_message(&stream, &Reply { answer }); + desktop_log!( + "desktop_ssh_prompt_completed prompt_id={id} outcome={outcome} duration_ms={}", + started.elapsed().as_millis() + ); + } +} + +/// Runs the helper when `ssh` started this executable as its askpass program. +/// Returns the exit code, or `None` for a normal Desktop start. +pub(crate) fn run_helper_if_requested() -> Option { + let address = std::env::var(ADDRESS_ENV).ok()?; + let token = std::env::var(TOKEN_ENV).unwrap_or_default(); + let prompt = std::env::args().nth(1).unwrap_or_default(); + let request = Request { + token, + prompt, + hint: std::env::var("SSH_ASKPASS_PROMPT").ok(), + label: std::env::var(LABEL_ENV).ok(), + }; + let answer = address + .parse::() + .ok() + .and_then(|address| TcpStream::connect_timeout(&address, Duration::from_secs(5)).ok()) + .and_then(|stream| { + write_message(&stream, &request).ok()?; + read_message::(&stream) + }) + .and_then(|reply| reply.answer); + match answer { + Some(answer) => { + let mut stdout = std::io::stdout(); + let _ = writeln!(stdout, "{answer}"); + let _ = stdout.flush(); + Some(0) + } + None => Some(1), + } +} + +fn read_message Deserialize<'de>>(stream: &TcpStream) -> Option { + let mut line = String::new(); + BufReader::new(stream.take(MAX_MESSAGE_BYTES)) + .read_line(&mut line) + .ok()?; + serde_json::from_str(&line).ok() +} + +fn write_message(mut stream: &TcpStream, message: &T) -> std::io::Result<()> { + let mut bytes = serde_json::to_vec(message).map_err(std::io::Error::other)?; + bytes.push(b'\n'); + stream.write_all(&bytes) +} + +fn same_token(given: &str, expected: &str) -> bool { + given.len() == expected.len() + && given + .bytes() + .zip(expected.bytes()) + .fold(0u8, |difference, (left, right)| difference | (left ^ right)) + == 0 +} + +#[cfg(test)] +#[path = "desktop_ssh_askpass_tests.rs"] +mod tests; diff --git a/apps/desktop/src-tauri/src/desktop_ssh_askpass_tests.rs b/apps/desktop/src-tauri/src/desktop_ssh_askpass_tests.rs new file mode 100644 index 00000000..c00009cb --- /dev/null +++ b/apps/desktop/src-tauri/src/desktop_ssh_askpass_tests.rs @@ -0,0 +1,31 @@ +use super::*; + +#[test] +fn prompts_are_classified_by_hint_and_wording() { + assert_eq!( + PromptKind::of("someone@host's password: ", None), + PromptKind::Secret + ); + assert_eq!( + PromptKind::of( + "Are you sure you want to continue connecting (yes/no/[fingerprint])? ", + None + ), + PromptKind::YesNo + ); + assert_eq!( + PromptKind::of("Allow use of key?", Some("confirm")), + PromptKind::YesNo + ); + assert_eq!( + PromptKind::of("Confirm user presence for key", Some("none")), + PromptKind::Notice + ); +} + +#[test] +fn only_the_handed_out_token_is_accepted() { + assert!(same_token("abc123", "abc123")); + assert!(!same_token("abc124", "abc123")); + assert!(!same_token("abc12", "abc123")); +} diff --git a/apps/desktop/src-tauri/src/desktop_ssh_config.rs b/apps/desktop/src-tauri/src/desktop_ssh_config.rs new file mode 100644 index 00000000..3d24f81f --- /dev/null +++ b/apps/desktop/src-tauri/src/desktop_ssh_config.rs @@ -0,0 +1,114 @@ +//! Host aliases from the user's `~/.ssh/config`, offered when adding an SSH +//! Computer. Only names are read; `ssh` itself applies the configuration. + +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; + +/// Bounds `Include` chains, which may refer to each other. +const MAX_INCLUDE_DEPTH: usize = 8; + +pub(crate) fn host_aliases(ssh_directory: &Path) -> Vec { + let mut aliases = BTreeSet::new(); + collect( + &ssh_directory.join("config"), + ssh_directory, + 0, + &mut aliases, + ); + aliases.into_iter().collect() +} + +fn collect(path: &Path, ssh_directory: &Path, depth: usize, aliases: &mut BTreeSet) { + if depth > MAX_INCLUDE_DEPTH { + return; + } + let Ok(text) = std::fs::read_to_string(path) else { + return; + }; + for line in text.lines() { + let Some((keyword, values)) = split_directive(line) else { + continue; + }; + if keyword.eq_ignore_ascii_case("host") { + aliases.extend( + values + .split_whitespace() + .map(|alias| alias.trim_matches('"')) + .filter(|alias| is_concrete(alias)) + .map(str::to_string), + ); + } else if keyword.eq_ignore_ascii_case("include") { + for pattern in values.split_whitespace() { + for included in expand_include(pattern.trim_matches('"'), ssh_directory) { + collect(&included, ssh_directory, depth + 1, aliases); + } + } + } + } +} + +/// `Keyword value…` or `Keyword=value…`, without comments. +fn split_directive(line: &str) -> Option<(&str, &str)> { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + return None; + } + let split = line.find(|character: char| character.is_whitespace() || character == '=')?; + let (keyword, rest) = line.split_at(split); + let rest = + rest.trim_start_matches(|character: char| character.is_whitespace() || character == '='); + Some((keyword, rest.split('#').next().unwrap_or_default().trim())) +} + +/// Patterns and negations match many hosts and name none. +fn is_concrete(alias: &str) -> bool { + !alias.is_empty() && !alias.contains(['*', '?', '!', '%']) +} + +/// A relative `Include` is under `~/.ssh`. Only the file name may hold `*`, +/// which covers the common `config.d/*` layouts. +fn expand_include(pattern: &str, ssh_directory: &Path) -> Vec { + let path = if let Some(rest) = pattern.strip_prefix("~/") { + match ssh_directory.parent() { + Some(home) => home.join(rest), + None => return Vec::new(), + } + } else if Path::new(pattern).is_absolute() { + PathBuf::from(pattern) + } else { + ssh_directory.join(pattern) + }; + let name = path + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_default(); + if !name.contains('*') { + return vec![path]; + } + let (prefix, suffix) = name.split_once('*').unwrap_or((&name, "")); + let Some(directory) = path.parent() else { + return Vec::new(); + }; + let Ok(entries) = std::fs::read_dir(directory) else { + return Vec::new(); + }; + let mut matches: Vec = entries + .flatten() + .map(|entry| entry.path()) + .filter(|candidate| { + candidate.is_file() + && candidate.file_name().is_some_and(|file| { + let file = file.to_string_lossy(); + file.len() >= prefix.len() + suffix.len() + && file.starts_with(prefix) + && file.ends_with(suffix) + }) + }) + .collect(); + matches.sort(); + matches +} + +#[cfg(test)] +#[path = "desktop_ssh_config_tests.rs"] +mod tests; diff --git a/apps/desktop/src-tauri/src/desktop_ssh_config_tests.rs b/apps/desktop/src-tauri/src/desktop_ssh_config_tests.rs new file mode 100644 index 00000000..b95f9fa2 --- /dev/null +++ b/apps/desktop/src-tauri/src/desktop_ssh_config_tests.rs @@ -0,0 +1,32 @@ +use super::*; + +#[test] +fn lists_concrete_aliases_including_included_files() { + let home = tempfile::tempdir().unwrap(); + let ssh = home.path().join(".ssh"); + std::fs::create_dir_all(ssh.join("config.d")).unwrap(); + std::fs::write( + ssh.join("config"), + "# personal\nInclude config.d/*.conf\nHost devbox build-1 *.internal !bad\n HostName 10.0.0.2\nHost=\"quoted\" # note\nMatch host x\n", + ) + .unwrap(); + std::fs::write(ssh.join("config.d/vm.conf"), "Host vm\n").unwrap(); + std::fs::write(ssh.join("config.d/ignored.txt"), "Host ignored\n").unwrap(); + + assert_eq!(host_aliases(&ssh), ["build-1", "devbox", "quoted", "vm"]); +} + +#[test] +fn includes_that_loop_or_are_missing_end_quietly() { + let home = tempfile::tempdir().unwrap(); + let ssh = home.path().join(".ssh"); + std::fs::create_dir_all(&ssh).unwrap(); + std::fs::write( + ssh.join("config"), + "Include config\nInclude missing\nHost a\n", + ) + .unwrap(); + + assert_eq!(host_aliases(&ssh), ["a"]); + assert!(host_aliases(&home.path().join("absent")).is_empty()); +} diff --git a/apps/desktop/src-tauri/src/desktop_ssh_release.rs b/apps/desktop/src-tauri/src/desktop_ssh_release.rs new file mode 100644 index 00000000..56874d7b --- /dev/null +++ b/apps/desktop/src-tauri/src/desktop_ssh_release.rs @@ -0,0 +1,152 @@ +//! The Linux x64 App Server this Desktop installs on SSH Computers (ADR-0069). +//! +//! It must be exactly this Desktop's release. The Windows Desktop already +//! bundles that binary for WSL. The macOS Desktop downloads the published Linux +//! archive once, keeps the binary in its cache, and trusts it only when its +//! SHA-256 matches the one the release build compiled in. + +use std::fs::File; +use std::io::Read; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +use openaide_remote_client::ServerRelease; +use openaide_remote_client::archive::{self, extract_verified, sha256_file}; + +/// Set by the release build from the Linux binary it publishes. +const BUILT_IN_SHA256: Option<&str> = option_env!("OPENAIDE_SSH_APP_SERVER_SHA256"); +const RELEASES: &str = "https://github.com/OldKrab/OpenAIDE/releases/download"; +const MAX_ARCHIVE_BYTES: u64 = 256 * 1024 * 1024; +const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(600); + +pub(crate) struct ReleaseSource { + pub(crate) release: ServerRelease, + origin: Origin, +} + +enum Origin { + /// A binary already on this computer whose checksum was just computed. + File(PathBuf), + Download { + url: String, + cache: PathBuf, + sha256: String, + }, +} + +/// Finds this Desktop's release binary. Errors are stable classes. +pub(crate) fn resolve( + app_version: &str, + wsl_resource_binary: &Path, + cache_root: &Path, +) -> Result { + if let Some(source) = development_override(app_version)? { + return Ok(source); + } + if cfg!(target_os = "windows") && wsl_resource_binary.is_file() { + return local(app_version, wsl_resource_binary.to_path_buf()); + } + let sha256 = BUILT_IN_SHA256.ok_or("unavailable_in_build")?; + let release = ServerRelease::new(app_version, sha256).ok_or("unavailable_in_build")?; + Ok(ReleaseSource { + release, + origin: Origin::Download { + url: format!( + "{RELEASES}/v{app_version}/{}", + archive::asset_name(app_version) + ), + cache: cache_root + .join("ssh-app-server") + .join(app_version) + .join("openaide-app-server"), + sha256: sha256.to_string(), + }, + }) +} + +/// A development Desktop has no published release; it may name a Linux build +/// of the App Server and the version it reports. +fn development_override(app_version: &str) -> Result, &'static str> { + if !cfg!(debug_assertions) { + return Ok(None); + } + let Some(path) = std::env::var_os("OPENAIDE_DESKTOP_SSH_APP_SERVER") else { + return Ok(None); + }; + let version = std::env::var("OPENAIDE_DESKTOP_SSH_APP_SERVER_VERSION") + .unwrap_or_else(|_| app_version.to_string()); + local(&version, PathBuf::from(path)).map(Some) +} + +fn local(version: &str, path: PathBuf) -> Result { + let sha256 = sha256_file(&path).map_err(|_| "release_unreadable")?; + Ok(ReleaseSource { + release: ServerRelease::new(version, &sha256).ok_or("unavailable_in_build")?, + origin: Origin::File(path), + }) +} + +impl ReleaseSource { + /// Opens the binary, downloading it first if the cache lacks it. Blocks. + pub(crate) fn open(&self) -> Result, &'static str> { + match &self.origin { + Origin::File(path) => File::open(path) + .map(|file| Box::new(file) as Box) + .map_err(|_| "release_unreadable"), + Origin::Download { url, cache, sha256 } => { + if sha256_file(cache).ok().as_deref() != Some(sha256.as_str()) { + download(url, cache, sha256)?; + } + File::open(cache) + .map(|file| Box::new(file) as Box) + .map_err(|_| "release_unreadable") + } + } + } +} + +fn download(url: &str, cache: &Path, sha256: &str) -> Result<(), &'static str> { + let started = Instant::now(); + desktop_log!("desktop_ssh_release_download_started attempt_count=1"); + let result = tauri::async_runtime::block_on(fetch_archive(url)) + .and_then(|archive| extract_verified(archive.as_slice(), cache, sha256)); + desktop_log!( + "desktop_ssh_release_download_completed outcome={} error_class={} duration_ms={} attempt_count=1", + if result.is_ok() { "success" } else { "failure" }, + result.err().unwrap_or("none"), + started.elapsed().as_millis() + ); + result +} + +async fn fetch_archive(url: &str) -> Result, &'static str> { + let client = reqwest::Client::builder() + .connect_timeout(Duration::from_secs(15)) + .timeout(DOWNLOAD_TIMEOUT) + .build() + .map_err(|_| "download_failed")?; + let mut response = client + .get(url) + .send() + .await + .map_err(|_| "download_failed")?; + if !response.status().is_success() { + return Err(if response.status() == reqwest::StatusCode::NOT_FOUND { + "release_not_published" + } else { + "download_failed" + }); + } + let mut archive = Vec::new(); + while let Some(chunk) = response.chunk().await.map_err(|_| "download_failed")? { + if archive.len() as u64 + chunk.len() as u64 > MAX_ARCHIVE_BYTES { + return Err("download_too_large"); + } + archive.extend_from_slice(&chunk); + } + Ok(archive) +} + +#[cfg(test)] +#[path = "desktop_ssh_release_tests.rs"] +mod tests; diff --git a/apps/desktop/src-tauri/src/desktop_ssh_release_tests.rs b/apps/desktop/src-tauri/src/desktop_ssh_release_tests.rs new file mode 100644 index 00000000..58aacd6b --- /dev/null +++ b/apps/desktop/src-tauri/src/desktop_ssh_release_tests.rs @@ -0,0 +1,17 @@ +use std::io::Read; + +use super::*; + +#[test] +fn a_bundled_binary_is_its_own_release() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("openaide-app-server"); + std::fs::write(&path, b"binary").unwrap(); + + let source = local("0.11.0", path).unwrap(); + + assert_eq!(source.release.version(), "0.11.0"); + let mut opened = Vec::new(); + source.open().unwrap().read_to_end(&mut opened).unwrap(); + assert_eq!(opened, b"binary"); +} diff --git a/apps/desktop/src-tauri/src/desktop_ssh_tests.rs b/apps/desktop/src-tauri/src/desktop_ssh_tests.rs new file mode 100644 index 00000000..5f7437a1 --- /dev/null +++ b/apps/desktop/src-tauri/src/desktop_ssh_tests.rs @@ -0,0 +1,90 @@ +use openaide_remote_client::TransportError; + +use super::*; + +#[test] +fn failures_name_the_computer_and_never_the_raw_error() { + let failure = LaunchError::Transport { + stage: Stage::Probe, + error: TransportError { + class: "auth_failed", + }, + }; + + assert_eq!( + message(&failure, "devbox"), + "devbox did not accept the sign-in. Check your SSH key or password." + ); + assert!( + message( + &LaunchError::ServerNewer { + running_version: "0.12.0".into() + }, + "devbox" + ) + .contains("0.12.0") + ); + assert!( + message(&LaunchError::Install("checksum_tool_missing"), "devbox").contains("sha256sum") + ); +} + +/// Drives the system `ssh` and the shared launcher against a real Linux x64 +/// host: install, start, forward, attach again, and stop. Run it by hand with +/// `OPENAIDE_SSH_E2E_DESTINATION` (a host `ssh` reaches without prompts), +/// `OPENAIDE_SSH_E2E_BINARY` (a Linux x64 `openaide-app-server`), and +/// `OPENAIDE_SSH_E2E_VERSION` (that binary's version), then +/// `cargo test against_a_real_ssh_host -- --ignored`. +#[test] +#[ignore = "needs an SSH host; see the doc comment"] +fn against_a_real_ssh_host() { + use std::io::{Read, Write}; + + use openaide_remote_client::ServerRelease; + use sha2::{Digest, Sha256}; + + let variable = |name| std::env::var(name).unwrap_or_else(|_| panic!("{name} is not set")); + let destination = variable("OPENAIDE_SSH_E2E_DESTINATION"); + let binary = std::fs::read(variable("OPENAIDE_SSH_E2E_BINARY")).unwrap(); + let sha256 = format!("{:x}", Sha256::digest(&binary)); + let release = ServerRelease::new(&variable("OPENAIDE_SSH_E2E_VERSION"), &sha256).unwrap(); + let launcher = SshLauncher::new(release, crate::desktop_remote::logger(), Box::new(|_| {})); + let mut transport = SystemSsh::new(&destination, Vec::new()); + let launch = |transport: &mut SystemSsh, operation_id: &str| { + let mut fetch = || Ok(Box::new(std::io::Cursor::new(binary.clone())) as Box); + match launcher.launch(transport, StartMode::Keep, &mut fetch, operation_id) { + Ok(Launched::Ready(ready)) => ready, + Ok(Launched::UpdateRequired { running_version }) => { + panic!("the host runs an incompatible {running_version}") + } + Err(error) => panic!("{} failed: {}", error.stage().as_str(), error.class()), + } + }; + + let first = launch(&mut transport, "e2e-1"); + let url = url::Url::parse(&first.endpoint_url).unwrap(); + let mut stream = std::net::TcpStream::connect(( + url.host_str().unwrap(), + url.port_or_known_default().unwrap(), + )) + .unwrap(); + write!( + stream, + "GET {} HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n", + url.path() + ) + .unwrap(); + let mut response = String::new(); + stream.read_to_string(&mut response).unwrap(); + assert!(response.starts_with("HTTP/1.1 "), "{response}"); + + let second = launch(&mut transport, "e2e-2"); + assert!(second.keeper_existed); + assert_eq!(second.auth_token, first.auth_token); + drop((first, second)); + + assert_eq!( + launcher.stop(&mut transport, "e2e-3").unwrap(), + StopOutcome::Stopped + ); +} diff --git a/apps/desktop/src-tauri/src/desktop_ssh_transport.rs b/apps/desktop/src-tauri/src/desktop_ssh_transport.rs new file mode 100644 index 00000000..9b06acd5 --- /dev/null +++ b/apps/desktop/src-tauri/src/desktop_ssh_transport.rs @@ -0,0 +1,411 @@ +//! The system `ssh` as the shared SSH launcher's transport (ADR-0069), so the +//! user's SSH configuration, jump hosts, agent, and hardware keys keep working. +//! +//! Each launch command is one `ssh` run. On macOS and Linux those runs share one +//! connection through `ControlMaster`, so a password is asked once per launch; +//! Windows' OpenSSH has no multiplexing and asks per run. The forward is its own +//! long-lived `ssh` that a supervisor restarts after a network drop, keeping the +//! same local port so the WebView's endpoint stays valid. + +use std::io::{Read, Write}; +use std::net::{SocketAddr, TcpListener, TcpStream}; +use std::path::PathBuf; +use std::process::{Child, Command, Stdio}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use openaide_remote_client::{RemoteOutput, SshTransport, TransportError}; + +/// Options every run shares. `ForwardX11=no` because askpass may set a +/// placeholder `DISPLAY`. +const COMMON_OPTIONS: &[&str] = &[ + "-o", + "ConnectTimeout=20", + "-o", + "ServerAliveInterval=15", + "-o", + "ServerAliveCountMax=3", + "-o", + "ForwardX11=no", +]; +/// Covers a person typing a password or touching a key after `ssh` starts. +const FORWARD_READY_LIMIT: Duration = Duration::from_secs(330); +const FORWARD_POLL: Duration = Duration::from_millis(100); +const RESTART_BACKOFF_LIMIT: Duration = Duration::from_secs(30); +const MAX_STDERR_BYTES: u64 = 64 * 1024; +#[cfg(target_os = "windows")] +const DETACHED_PROCESS: u32 = 0x0000_0008; + +pub(crate) struct SystemSsh { + destination: String, + environment: Vec<(&'static str, String)>, + control_path: Option, +} + +impl SystemSsh { + /// `environment` makes `ssh` ask its questions in this Desktop. + pub(crate) fn new(destination: &str, environment: Vec<(&'static str, String)>) -> Self { + Self { + destination: destination.to_string(), + environment, + control_path: control_path(), + } + } + + fn command(&self) -> Command { + let mut command = Command::new("ssh"); + command.args(COMMON_OPTIONS); + command.envs(self.environment.iter().map(|(name, value)| (name, value))); + #[cfg(target_os = "windows")] + { + // Without a console, Windows' OpenSSH reads prompts through askpass + // instead of a hidden console nobody can type into. + // TODO: verify on Windows that askpass starts the helper when the + // Desktop's install path holds spaces; only Linux has run it so far. + use std::os::windows::process::CommandExt; + command.creation_flags(DETACHED_PROCESS); + } + command + } +} + +impl SshTransport for SystemSsh { + type Forward = SshForward; + + fn run( + &mut self, + remote_command: &str, + input: Option<&mut dyn Read>, + ) -> Result { + let mut command = self.command(); + command.args(["-T", "-o", "ClearAllForwardings=yes"]); + if let Some(path) = &self.control_path { + command.args([ + "-o", + "ControlMaster=auto", + "-o", + &format!("ControlPath={path}"), + "-o", + "ControlPersist=60", + ]); + } + command + .args(["--", &self.destination, remote_command]) + .stdin(if input.is_some() { + Stdio::piped() + } else { + Stdio::null() + }) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let mut child = command.spawn().map_err(spawn_failure)?; + let stderr = collect_stderr(&mut child); + let mut stdout = child.stdout.take().expect("piped stdout"); + let reader = std::thread::spawn(move || { + let mut bytes = Vec::new(); + let _ = stdout.read_to_end(&mut bytes); + bytes + }); + if let (Some(input), Some(mut stdin)) = (input, child.stdin.take()) { + // A broken pipe means `ssh` ended early; its status says why. + let _ = std::io::copy(input, &mut stdin).and_then(|_| stdin.flush()); + } + let status = child.wait().map_err(|_| TransportError { + class: "ssh_failed", + })?; + let stdout = reader.join().unwrap_or_default(); + let stderr = stderr.join().unwrap_or_default(); + // 255 is `ssh`'s own failure; any other status is the remote command's. + if status.code() == Some(255) { + return Err(TransportError { + class: classify(&stderr), + }); + } + Ok(RemoteOutput { + success: status.success(), + stdout, + }) + } + + fn forward(&mut self, remote_port: u16) -> Result<(SshForward, SocketAddr), TransportError> { + let local_port = free_local_port()?; + let mut command = self.command(); + // TODO: a `LocalForward` in the user's configuration for this host + // also binds here, and a taken port fails the forward under + // `ExitOnForwardFailure`. `-o ClearAllForwardings=yes` would drop + // ours too, so a fix needs `-F` with a filtered configuration. + command.args([ + "-N", + "-o", + "ExitOnForwardFailure=yes", + "-o", + "ControlPath=none", + "-L", + &format!("127.0.0.1:{local_port}:127.0.0.1:{remote_port}"), + "--", + &self.destination, + ]); + command + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()); + let local = SocketAddr::from(([127, 0, 0, 1], local_port)); + let child = start_forward(&mut command, local)?; + Ok((SshForward::supervise(command, child, local), local)) + } +} + +/// Keeps the forward up while it is held; dropping it ends the `ssh`. +pub(crate) struct SshForward { + stopping: Arc, + child: Arc>>, +} + +impl SshForward { + fn supervise(mut command: Command, child: Child, local: SocketAddr) -> Self { + let stopping = Arc::new(AtomicBool::new(false)); + let current = Arc::new(Mutex::new(Some(child))); + let forward = Self { + stopping: stopping.clone(), + child: current.clone(), + }; + let _ = std::thread::Builder::new() + .name("ssh-forward".to_string()) + .spawn(move || { + let mut attempt = 0u32; + loop { + wait_for_exit(¤t, &stopping); + if stopping.load(Ordering::Acquire) { + return; + } + attempt += 1; + let backoff = Duration::from_secs(1 << attempt.min(5)).min(RESTART_BACKOFF_LIMIT); + desktop_log!( + "desktop_ssh_forward_lost attempt_count={attempt} retry_in_ms={}", + backoff.as_millis() + ); + if sleep_unless_stopping(backoff, &stopping) { + return; + } + let started = Instant::now(); + match start_forward(&mut command, local) { + Ok(child) => { + desktop_log!( + "desktop_ssh_forward_restored attempt_count={attempt} duration_ms={}", + started.elapsed().as_millis() + ); + attempt = 0; + if let Ok(mut slot) = current.lock() { + *slot = Some(child); + } + } + Err(error) => desktop_log!( + "desktop_ssh_forward_restart_failed attempt_count={attempt} error_class={} duration_ms={}", + error.class, + started.elapsed().as_millis() + ), + } + } + }); + forward + } +} + +impl Drop for SshForward { + fn drop(&mut self) { + self.stopping.store(true, Ordering::Release); + if let Ok(mut slot) = self.child.lock() + && let Some(mut child) = slot.take() + { + let _ = child.kill(); + let _ = child.wait(); + } + desktop_log!("desktop_ssh_forward_closed"); + } +} + +/// Starts the forward `ssh` and returns once the local port answers, which is +/// after any prompt and the remote's acceptance of the forward. +fn start_forward(command: &mut Command, local: SocketAddr) -> Result { + let started = Instant::now(); + let mut child = command.spawn().map_err(spawn_failure)?; + let stderr = collect_stderr(&mut child); + loop { + if let Ok(Some(_)) = child.try_wait() { + return Err(TransportError { + class: match classify(&stderr.join().unwrap_or_default()) { + "ssh_failed" => "forward_failed", + class => class, + }, + }); + } + if TcpStream::connect_timeout(&local, FORWARD_POLL).is_ok() + && matches!(child.try_wait(), Ok(None)) + { + desktop_log!( + "desktop_ssh_forward_ready wait_ms={}", + started.elapsed().as_millis() + ); + return Ok(child); + } + if started.elapsed() > FORWARD_READY_LIMIT { + let _ = child.kill(); + let _ = child.wait(); + return Err(TransportError { + class: "forward_timeout", + }); + } + std::thread::sleep(FORWARD_POLL); + } +} + +fn wait_for_exit(child: &Mutex>, stopping: &AtomicBool) { + loop { + if stopping.load(Ordering::Acquire) { + return; + } + let exited = match child.lock() { + Ok(mut slot) => match slot.as_mut() { + Some(child) => !matches!(child.try_wait(), Ok(None)), + None => true, + }, + Err(_) => true, + }; + if exited { + return; + } + std::thread::sleep(Duration::from_millis(500)); + } +} + +/// True when stopped during the wait. +fn sleep_unless_stopping(duration: Duration, stopping: &AtomicBool) -> bool { + let until = Instant::now() + duration; + while Instant::now() < until { + if stopping.load(Ordering::Acquire) { + return true; + } + std::thread::sleep(Duration::from_millis(200)); + } + stopping.load(Ordering::Acquire) +} + +/// `ssh -L` cannot choose a port and report it, so the shell picks a free one. +/// Another process taking it in between makes `ssh` fail the forward. +fn free_local_port() -> Result { + TcpListener::bind(("127.0.0.1", 0)) + .and_then(|listener| listener.local_addr()) + .map(|address| address.port()) + .map_err(|_| TransportError { + class: "local_port_unavailable", + }) +} + +fn collect_stderr(child: &mut Child) -> std::thread::JoinHandle { + let stderr = child.stderr.take(); + std::thread::spawn(move || { + let mut text = String::new(); + if let Some(stderr) = stderr { + let _ = stderr.take(MAX_STDERR_BYTES).read_to_string(&mut text); + } + text + }) +} + +fn spawn_failure(error: std::io::Error) -> TransportError { + TransportError { + class: if error.kind() == std::io::ErrorKind::NotFound { + "ssh_missing" + } else { + "ssh_failed" + }, + } +} + +/// Classifies `ssh`'s own diagnostics. The text itself names hosts and users +/// and never leaves this function. +pub(crate) fn classify(stderr: &str) -> &'static str { + let has = |needles: &[&str]| needles.iter().any(|needle| stderr.contains(needle)); + if has(&[ + "Host key verification failed", + "REMOTE HOST IDENTIFICATION HAS CHANGED", + ]) { + "host_key_rejected" + } else if has(&["Permission denied", "Too many authentication failures"]) { + "auth_failed" + } else if has(&[ + "Could not resolve hostname", + "Name or service not known", + "nodename nor servname", + ]) { + "host_unknown" + } else if has(&["Connection refused"]) { + "connection_refused" + } else if has(&[ + "timed out", + "No route to host", + "Network is unreachable", + "Connection closed", + "Connection reset", + ]) { + "unreachable" + } else if has(&[ + "Bad configuration option", + "Bad owner or permissions", + "garbage at end of line", + "Unsupported option", + ]) { + "ssh_config_invalid" + } else if has(&[ + "forwarding failed", + "cannot listen to port", + "Address already in use", + ]) { + "forward_failed" + } else { + "ssh_failed" + } +} + +/// A place for the control socket whose directory only this user can enter, +/// short enough for a socket path, or none to run without multiplexing. +fn control_path() -> Option { + #[cfg(target_os = "macos")] + let base = Some(std::env::temp_dir()); + #[cfg(target_os = "linux")] + let base = std::env::var_os("XDG_RUNTIME_DIR").map(PathBuf::from); + // TODO: multiplex on Windows too. Its OpenSSH has no ControlMaster, so a + // password user answers once per `ssh` run; a long-lived session or an + // embedded client, as planned for Android, would ask once per start. + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + let base: Option = None; + let directory = base?.join("oa-ssh"); + #[cfg(unix)] + { + use std::os::unix::fs::{DirBuilderExt, PermissionsExt}; + let _ = std::fs::DirBuilder::new().mode(0o700).create(&directory); + let metadata = std::fs::metadata(&directory).ok()?; + if !metadata.is_dir() || metadata.permissions().mode() & 0o077 != 0 { + return None; + } + } + let path = directory.join("%C").to_string_lossy().into_owned(); + // `%C` expands to 40 characters; Unix socket paths stop near 104 bytes. + (path.len() + 40 < 100 && !path.contains(' ')).then_some(path) +} + +/// A destination is passed to `ssh` as one argument after `--`; anything that +/// could read as an option or split into several words is refused. +pub(crate) fn is_destination(text: &str) -> bool { + !text.is_empty() + && text.len() <= 255 + && !text.starts_with('-') + && text + .chars() + .all(|character| !character.is_whitespace() && !character.is_control()) +} + +#[cfg(test)] +#[path = "desktop_ssh_transport_tests.rs"] +mod tests; diff --git a/apps/desktop/src-tauri/src/desktop_ssh_transport_tests.rs b/apps/desktop/src-tauri/src/desktop_ssh_transport_tests.rs new file mode 100644 index 00000000..942e0afa --- /dev/null +++ b/apps/desktop/src-tauri/src/desktop_ssh_transport_tests.rs @@ -0,0 +1,46 @@ +use super::*; + +#[test] +fn ssh_diagnostics_become_classes() { + let cases = [ + ("Host key verification failed.", "host_key_rejected"), + ( + "someone@host: Permission denied (publickey,password).", + "auth_failed", + ), + ( + "ssh: Could not resolve hostname nowhere: Name or service not known", + "host_unknown", + ), + ( + "ssh: connect to host h port 22: Connection refused", + "connection_refused", + ), + ( + "ssh: connect to host h port 22: Operation timed out", + "unreachable", + ), + ( + "/home/someone/.ssh/config: line 3: Bad configuration option: foo", + "ssh_config_invalid", + ), + ( + "Error: remote port forwarding failed for listen port 1", + "forward_failed", + ), + ("something new", "ssh_failed"), + ]; + for (stderr, class) in cases { + assert_eq!(classify(stderr), class, "{stderr}"); + } +} + +#[test] +fn a_destination_cannot_become_an_option_or_several_words() { + assert!(is_destination("devbox")); + assert!(is_destination("ssh://someone@build.example:2222")); + assert!(!is_destination("-oProxyCommand=touch")); + assert!(!is_destination("devbox other")); + assert!(!is_destination("devbox\n")); + assert!(!is_destination("")); +} diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index 28ddb5c5..022964db 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -1,13 +1,10 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] use std::path::PathBuf; -use std::sync::{ - Mutex, - atomic::{AtomicBool, Ordering}, -}; -#[cfg(any(target_os = "macos", target_os = "windows"))] +use std::sync::Mutex; +#[cfg(all(target_os = "macos", debug_assertions))] use std::thread; -#[cfg(any(target_os = "macos", target_os = "windows"))] +#[cfg(all(target_os = "macos", debug_assertions))] use std::time::Duration; use serde::Serialize; @@ -20,10 +17,16 @@ mod desktop_app_server_hold; mod desktop_app_server_launch; mod desktop_connection; mod desktop_local_attention; +mod desktop_quit; mod desktop_remote; mod desktop_renderer_protocol; mod desktop_runtime; mod desktop_secrets; +mod desktop_ssh; +mod desktop_ssh_askpass; +mod desktop_ssh_config; +mod desktop_ssh_release; +mod desktop_ssh_transport; mod desktop_support_export; mod desktop_update; mod desktop_update_receipt; @@ -84,10 +87,13 @@ impl DesktopState { wsl_resource_binary, } } - } fn main() { + // `ssh` starts this executable to ask the user something; it opens no window. + if let Some(code) = desktop_ssh_askpass::run_helper_if_requested() { + std::process::exit(code); + } tauri::Builder::default() .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_notification::init()) @@ -117,10 +123,13 @@ fn main() { )); app.manage(desktop_connection::DesktopConnections::default()); app.manage(desktop_remote::DesktopRemoteState::new(&app_local_data)); + app.manage(desktop_ssh::DesktopSshState::new( + app.path().app_cache_dir()?, + )); app.manage(DesktopUpdateState::for_build( app_local_data.join("desktop-update-receipt.json"), )); - app.manage(DesktopQuitState::default()); + app.manage(desktop_quit::DesktopQuitState::default()); desktop_app_server_hold::install(app); desktop_local_attention::install(app); #[cfg(target_os = "macos")] @@ -132,9 +141,9 @@ fn main() { Ok(()) }) .on_menu_event(|app, event| { - #[cfg(target_os = "macos")] if event.id().as_ref() == "quit" { - arm_macos_quit_watchdog(app); + desktop_quit::request_quit(app); + return; } let _ = app.emit("desktop-command", event.id().as_ref()); }) @@ -144,33 +153,17 @@ fn main() { && let tauri::WindowEvent::CloseRequested { api, .. } = event { // Native close and the custom caption button must cross the same - // detach boundary as explicit Quit before this process disappears. + // detach boundary, and the same Terminal warning, as explicit Quit. api.prevent_close(); - window - .state::() - .pending - .store(true, Ordering::Release); - let app = window.app_handle().clone(); - let _ = app.emit("desktop-command", "quit"); - thread::spawn(move || { - thread::sleep(Duration::from_secs(5)); - if app - .state::() - .pending - .swap(false, Ordering::AcqRel) - { - // Startup may not have produced a frontend session that can - // detach. Never leave an unclosable native window behind. - app.exit(0); - } - }); + desktop_quit::request_quit(window.app_handle()); } }) .invoke_handler(tauri::generate_handler![ desktop_connection::desktop_bootstrap, desktop_bootstrap_context, desktop_connection::desktop_ensure_app_server, - desktop_take_pending_quit, + desktop_quit::desktop_take_pending_quit, + desktop_quit::desktop_set_open_terminals, desktop_app_server_hold::complete_desktop_quit, desktop_app_server_hold::desktop_update_prepare_local, desktop_local_attention::desktop_local_attention_snapshot, @@ -192,6 +185,14 @@ fn main() { desktop_remote::desktop_remote_use, desktop_remote::desktop_remote_cancel, desktop_remote::desktop_remote_forget, + desktop_ssh::desktop_ssh_hosts, + desktop_ssh::desktop_ssh_add, + desktop_ssh::desktop_ssh_status, + desktop_ssh::desktop_ssh_failure, + desktop_ssh::desktop_ssh_update, + desktop_ssh::desktop_ssh_stop, + desktop_ssh::desktop_ssh_prompts, + desktop_ssh::desktop_ssh_prompt_answer, desktop_update::desktop_update_snapshot, desktop_update::desktop_check_for_update, desktop_update::desktop_auto_check_for_update, @@ -204,18 +205,6 @@ fn main() { .expect("failed to run OpenAIDE Desktop App"); } -#[derive(Default)] -struct DesktopQuitState { - // Native close can arrive before the WebView has registered its command - // listener. The WebView consumes this latch after crossing that boundary. - pending: AtomicBool, -} - -#[tauri::command] -fn desktop_take_pending_quit(state: tauri::State<'_, DesktopQuitState>) -> bool { - state.pending.swap(false, Ordering::AcqRel) -} - #[tauri::command] fn desktop_bootstrap_context( state: tauri::State<'_, DesktopState>, @@ -286,30 +275,6 @@ fn watch_development_runner(app: &tauri::App) { }); } -/// Guarantees explicit Quit on macOS even when the WebView cannot run its -/// graceful detach. The frontend owns the normal path; this watchdog uses the -/// same budget as the Windows close fallback and then terminates natively. -#[cfg(target_os = "macos")] -fn arm_macos_quit_watchdog(app: &tauri::AppHandle) { - { - let state = app.state::(); - if state.pending.swap(true, Ordering::AcqRel) { - return; - } - } - let app = app.clone(); - thread::spawn(move || { - thread::sleep(Duration::from_secs(5)); - if app - .state::() - .pending - .swap(false, Ordering::AcqRel) - { - app.exit(0); - } - }); -} - #[tauri::command] fn set_desktop_runtime_environment( app: tauri::AppHandle, diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index 600ace35..8abb5a17 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -11,7 +11,7 @@ }, "app": { "security": { - "csp": "default-src 'self'; frame-src 'self' openaide-renderer: http://openaide-renderer.localhost; connect-src http://127.0.0.1:* http://localhost:* ipc: http://ipc.localhost; font-src 'self' asset: http://asset.localhost; img-src 'self' asset: http://asset.localhost data:; style-src 'self' 'unsafe-inline'" + "csp": "default-src 'self'; frame-src 'self' openaide-renderer: http://openaide-renderer.localhost; connect-src http://127.0.0.1:* http://localhost:* ws://127.0.0.1:* ws://localhost:* ipc: http://ipc.localhost; font-src 'self' asset: http://asset.localhost; img-src 'self' asset: http://asset.localhost data:; style-src 'self' 'unsafe-inline'" }, "windows": [ { diff --git a/apps/vscode-extension/frontend/vsCodeShell.ts b/apps/vscode-extension/frontend/vsCodeShell.ts index bbe8bca3..346fcd22 100644 --- a/apps/vscode-extension/frontend/vsCodeShell.ts +++ b/apps/vscode-extension/frontend/vsCodeShell.ts @@ -201,6 +201,9 @@ export function createVsCodeShell(): FrontendShell { workspace: { openFolder: () => vscode?.postMessage({ type: "workspace.openFolder" }), }, + nativeTerminal: { + open: (folder) => vscode?.postMessage({ type: "shell.terminal.open", payload: { folder } }), + }, projects: { pickFolder() { if (!vscode) return Promise.reject(new Error("VS Code folder picker unavailable.")); diff --git a/apps/vscode-extension/src/runtime/appServerHostClient.test.ts b/apps/vscode-extension/src/runtime/appServerHostClient.test.ts index f1ac502b..61c0e247 100644 --- a/apps/vscode-extension/src/runtime/appServerHostClient.test.ts +++ b/apps/vscode-extension/src/runtime/appServerHostClient.test.ts @@ -63,7 +63,6 @@ describe("AppServerHostClient", () => { "revealFile", "resolveFileReveal", "pickLocalFile", - "openTerminal", "readSecret", "writeSecret", "showNotification", diff --git a/apps/vscode-extension/src/runtime/appServerHostClient.ts b/apps/vscode-extension/src/runtime/appServerHostClient.ts index 010ffe89..c7c9f40d 100644 --- a/apps/vscode-extension/src/runtime/appServerHostClient.ts +++ b/apps/vscode-extension/src/runtime/appServerHostClient.ts @@ -310,7 +310,6 @@ export class AppServerHostClient { "revealFile", "resolveFileReveal", "pickLocalFile", - "openTerminal", "readSecret", "writeSecret", "showNotification", diff --git a/apps/vscode-extension/src/webview/messaging.test.ts b/apps/vscode-extension/src/webview/messaging.test.ts index ad9c5f61..1d90873c 100644 --- a/apps/vscode-extension/src/webview/messaging.test.ts +++ b/apps/vscode-extension/src/webview/messaging.test.ts @@ -61,6 +61,7 @@ vi.mock("vscode", () => ({ parse: (value: string) => ({ value }), }, window: { + createTerminal: vi.fn(() => ({ show: vi.fn() })), showErrorMessage: vi.fn(), showInformationMessage: vi.fn(), showOpenDialog: vi.fn(), @@ -746,6 +747,18 @@ describe("webview messaging composer routes", () => { expect(vscode.commands.executeCommand).toHaveBeenCalledWith("workbench.action.reloadWindow"); }); + it("opens VS Code's own terminal at an absolute Task Workspace folder", async () => { + const posted: unknown[] = []; + vi.mocked(vscode.window.createTerminal).mockClear(); + await handleWebviewMessage({ type: "shell.terminal.open", payload: { folder: "/workspace/app" } }, context({}, posted)); + await handleWebviewMessage({ type: "shell.terminal.open", payload: { folder: "relative/app" } }, context({}, posted)); + + expect(vscode.window.createTerminal).toHaveBeenCalledTimes(1); + expect(vscode.window.createTerminal).toHaveBeenCalledWith({ name: "app", cwd: "/workspace/app" }); + const terminal = vi.mocked(vscode.window.createTerminal).mock.results[0].value as { show: ReturnType }; + expect(terminal.show).toHaveBeenCalled(); + }); + it("writes clipboard text through VS Code and acknowledges the request", async () => { const posted: unknown[] = []; diff --git a/apps/vscode-extension/src/webview/messagingShell.ts b/apps/vscode-extension/src/webview/messagingShell.ts index 2404d895..cbf91f3b 100644 --- a/apps/vscode-extension/src/webview/messagingShell.ts +++ b/apps/vscode-extension/src/webview/messagingShell.ts @@ -218,6 +218,13 @@ export async function routeHostCapabilityCommand(message: WebviewToHostMessage, await vscode.commands.executeCommand(inWorkspace ? "revealInExplorer" : "revealFileInOS", folder); return true; } + if (message.type === "shell.terminal.open" && isObject(message.payload)) { + const folder = typeof message.payload.folder === "string" ? message.payload.folder : ""; + if (!nodePath.isAbsolute(folder)) throw new Error("folder must be absolute"); + // ADR-0071: VS Code's own terminal replaces the in-app page; the action log carries only its type. + vscode.window.createTerminal({ name: nodePath.basename(folder) || "Terminal", cwd: folder }).show(); + return true; + } if (message.type === "tool.openPath" && isObject(message.payload)) { const path = typeof message.payload.path === "string" ? message.payload.path : ""; const line = typeof message.payload.line === "number" && message.payload.line > 0 ? message.payload.line : undefined; diff --git a/apps/web/frontend/webAppShell.ts b/apps/web/frontend/webAppShell.ts index 7487451d..85ae26c2 100644 --- a/apps/web/frontend/webAppShell.ts +++ b/apps/web/frontend/webAppShell.ts @@ -195,6 +195,13 @@ export function createWebAppShell(): FrontendShell { openExternal: openWebExternal, reload: () => window.location.reload(), }, + terminals: { + socketUrl() { + const url = new URL("/__openaide-app-server/terminal", window.location.href); + url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; + return url.toString(); + }, + }, fileViewer: true, fileViewerDownloads: { async save({ handle, label, operationId }, signal) { diff --git a/apps/web/src/dev-server-app-server-socket.mjs b/apps/web/src/dev-server-app-server-socket.mjs index e18f17d5..70fbd48b 100644 --- a/apps/web/src/dev-server-app-server-socket.mjs +++ b/apps/web/src/dev-server-app-server-socket.mjs @@ -20,6 +20,7 @@ export function createAppServerSocketProxy({ startAppServer, currentEndpoint, lo const context = { socket_id: `web-socket-${Date.now()}-${++nextSocketNumber}`, connection_id: url.searchParams.get("connectionId") ?? undefined, + route: terminalRoute(url) ? "terminal" : "session", }; let outcome; const finish = (reason, fields = {}) => { @@ -66,7 +67,9 @@ export function createAppServerSocketProxy({ startAppServer, currentEndpoint, lo const proxyReq = http.request({ hostname: endpoint.url.hostname, port: Number(endpoint.url.port), - path: endpoint.url.pathname + url.search, + // A Terminal byte stream (ADR-0071) sits beside the session socket; its + // single-use attach token travels inside the stream, never in the URL. + path: endpoint.url.pathname + (terminalRoute(url) ? "/terminal" : "") + url.search, method: "GET", headers, }); @@ -122,6 +125,10 @@ export function createAppServerSocketProxy({ startAppServer, currentEndpoint, lo }; } +function terminalRoute(url) { + return url.pathname === "/__openaide-app-server/terminal"; +} + function reject(socket, status, statusText) { if (socket.destroyed) return; socket.end(`HTTP/1.1 ${status} ${statusText}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`); diff --git a/apps/web/src/dev-server-app-server-socket.test.mjs b/apps/web/src/dev-server-app-server-socket.test.mjs index b52f08b7..88c40b12 100644 --- a/apps/web/src/dev-server-app-server-socket.test.mjs +++ b/apps/web/src/dev-server-app-server-socket.test.mjs @@ -44,6 +44,23 @@ test("relays an authenticated App Server socket in both directions", async (t) = assert.doesNotMatch(JSON.stringify(events), /app-server-token|browser-credential/); }); +test("relays a Terminal socket to the route beside the session endpoint", async (t) => { + const upstreamRequests = []; + const upstream = http.createServer(); + upstream.on("upgrade", (request, socket) => { + upstreamRequests.push(request); + socket.end("HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\r\n"); + }); + const { port, events } = await startProxy(t, upstream); + + const browser = await upgrade(port, "/__openaide-app-server/terminal"); + browser.socket.resume(); + await once(browser.socket, "close"); + + assert.equal(upstreamRequests[0].url, "/rpc/terminal"); + assert.equal(events[0].fields.route, "terminal"); +}); + test("reports the App Server closing a relayed socket", async (t) => { const upstream = http.createServer(); upstream.on("upgrade", (_request, socket) => { diff --git a/docs/adr/0069-ssh-computers-are-started-like-termux.md b/docs/adr/0069-ssh-computers-are-started-like-termux.md index a075617c..8bc72b47 100644 --- a/docs/adr/0069-ssh-computers-are-started-like-termux.md +++ b/docs/adr/0069-ssh-computers-are-started-like-termux.md @@ -1,6 +1,6 @@ # SSH Computers Are Started Like Termux -Status: proposed +Status: accepted The Desktop App and the Mobile App can make a Linux computer reachable over SSH the place where agents work. The App Shell installs the App Server there, starts it, and reaches it the way the Mobile App reaches its App Server in Termux: it holds the per-process token and is a client of that App Server, with the loopback endpoint carried through the SSH connection instead of a local socket. No Pairing Code, key exchange with the App Server, or relay is involved; the user's SSH access is the proof. Pairing with a computer ([ADR-0063](0063-desktop-can-use-another-computers-app-server.md)) stays the way to reach an App Server someone else started. diff --git a/docs/adr/0071-terminals-are-shells-on-the-app-servers-computer.md b/docs/adr/0071-terminals-are-shells-on-the-app-servers-computer.md new file mode 100644 index 00000000..6d3b4e62 --- /dev/null +++ b/docs/adr/0071-terminals-are-shells-on-the-app-servers-computer.md @@ -0,0 +1,30 @@ +# Terminals Are Shells on the App Server's Computer + +Status: accepted + +A user away from the computer, on a Remote Device ([ADR-0062](0062-remote-devices-are-app-server-clients.md)) or a Desktop App using another computer ([ADR-0063](0063-desktop-can-use-another-computers-app-server.md)), has no way to run a command there except by asking an Agent. A Terminal gives them an interactive shell on the App Server's computer. + +## Choice + +- A Terminal is the user's own login shell, run by the App Server in its own environment: inside the WSL distribution when the App Server runs there, PowerShell on native Windows. It belongs to no Task, and Agents neither see nor read it. +- It opens from a Project, starting in its root, or from a Task, starting in its Task Workspace. Navigation gains no entry. Several Terminals may be open, each closed explicitly. +- The App Server owns the shell. A Terminal survives its clients disconnecting and ends only when the user closes it, the shell exits, or the App Server stops. On reconnect a client receives the recent output, about 10,000 lines kept in memory and never written to disk. +- Every client that opens a Terminal sees it live and can type into it, like attaching to one `tmux` session. +- Open Terminals do not keep the App Server running. Stopping it, including for a Desktop Update, first warns that they will close. +- Every trusted client may open a Terminal. Opening, closing, and failures are logged as metadata; content never is. +- VS Code opens its own integrated terminal at the same folder instead. +- The App Server Protocol creates, lists, resizes, and closes Terminals. Keystrokes and output travel on a dedicated stream per Terminal, so typing never queues behind other traffic on a relayed path. + +## Considered Options + +- **Typing into Agent-run commands.** Rejected: ACP terminals have no input, and the protocol is moving toward Agent-owned, display-only terminals. +- **A Terminal that ends with its connection.** Rejected: a phone that locks its screen would kill every long command. +- **A Terminal that keeps the App Server running.** Rejected: it reintroduces the server that never stops, which [ADR-0064](0064-a-terminal-command-keeps-the-app-server-reachable.md) rejected. +- **A separate setting to allow Terminals.** Rejected: a trusted client can already run commands through a Custom Agent or MCP server, so the setting would look like protection without being one. +- **One writer at a time.** Left out: both clients are the same person. +- **Binding each stream's attach token to the client that asked for it.** Left out: the token reaches only a trusted client, over that client's own connection; it is single-use, expires in 30 seconds, and is never logged. Binding it would mean carrying client identity through every gateway in front of the stream, for no protection beyond what pairing already gives. + +## Consequences + +- A Remote Device's key is now directly a shell on the computer. Removing a device remains the only revocation. +- Terminals do not survive an App Server restart. diff --git a/docs/plans/terminals.md b/docs/plans/terminals.md new file mode 100644 index 00000000..8a447c6f --- /dev/null +++ b/docs/plans/terminals.md @@ -0,0 +1,57 @@ +# Terminals + +Status: contract agreed; presentation approved by prototype; steps 1–7 implemented, not yet released. + +## Agreed scope + +The decision and rejected options are in [ADR-0071](../adr/0071-terminals-are-shells-on-the-app-servers-computer.md). + +- A Terminal is the user's login shell on the App Server's computer, run in the App Server's environment (WSL distribution when App Server runs there, PowerShell on native Windows). It belongs to no Task and is invisible to Agents. +- Entry points: **Open terminal** in a Project's menu (starts in its root) and a **Terminal** action on the Task Page header (starts in its Task Workspace). Navigation gains no entry. +- Several Terminals may be open. App Server owns each shell; it survives client disconnects and ends on close, shell exit, or App Server stop. +- Reconnecting replays about the last 10,000 lines, held in memory only. +- Every client viewing a Terminal sees it live and may type. +- Closing a Terminal whose shell has a running foreground command asks for confirmation. +- Open Terminals do not keep App Server alive. Stopping App Server, including for a Desktop Update, first warns how many Terminals will close. +- Any trusted client may open a Terminal. Logs record open, close, exit, and failure as metadata only. +- VS Code opens an integrated terminal at the same folder instead of rendering one. + +## Approved presentation + +Approved from the `terminal` UI prototype (variant `page` on Desktop, chip tabs on Android). + +- **Desktop and Web:** the Terminal is a full Task Page destination, like Project files. A header holds **← Conversation**, the title **Terminal** with `Project · computer` beneath it, and the active Terminal's current folder at the right. Below it, a tab strip lists Terminals: a state dot (running, idle, exited), a monospace title (the running command, otherwise the folder name), and a close button; a **+** opens another Terminal. The terminal fills the rest. +- **Task header:** a **Terminal** button with the open-Terminal count sits beside Project files. +- **Android:** the same destination, full screen. The top bar holds back, **Terminal** with the computer name, and a keyboard show/hide button. Under it, a horizontally scrolling row of chip tabs with the same state dots and a **+**. A key row sits directly above the soft keyboard: ctrl (sticky, highlighted while armed), esc, tab, ^C, arrows, `|`, `~`, `/`, `-`. Tapping the key row never dismisses the keyboard. +- **Closing a running Terminal:** a small confirmation anchored to its tab: "Stop ``?", "Closing this terminal ends the command.", **Cancel** / **Close terminal**. +- **Reconnecting:** a warning-tinted strip above the terminal, "Reconnecting to ``… Your shells keep running."; input is ignored until reconnected. On reconnect the retained output reappears. +- **No Terminals:** an empty state with **New terminal in ``**. + +## Pre-implementation findings + +- App Server already runs one interactive PTY: Agent sign-in (`agent/auth_terminal.rs`, `portable-pty` with `TERM=xterm-256color`). Its byte path is a reverse request polled about every 100 ms with 32 KiB frames and a 30 s liveness deadline. That suits sign-in but not an interactive shell, so Terminals need a dedicated stream. +- The local HTTP listener already serves side channels beside the reliable session (`listener/uploads.rs`, `listener/viewer_downloads.rs`) and a WebSocket session link (`listener/session_socket.rs`). Remote Devices reach the same listener through the iroh bridge, which carries WebSocket upgrades, so a per-Terminal WebSocket route serves both same-machine and Remote clients without a second transport. +- The Frontend already bundles `@xterm/xterm` and `addon-fit` (used by `settings/AgentAuthTerminal.tsx`). +- `ShellCapability::OpenTerminal` is defined and advertised by clients but read nowhere; it is the natural capability for the VS Code path. +- Update Shutdown Readiness is decided in `protocol_edge/client_handlers.rs` and `responses.rs`; the Terminal warning joins that check. + +## Implementation outline + +1. **App Server Terminal registry** (done). `app-server/src/terminals.rs` owns the open Terminals; `terminals/shell.rs` runs one login shell (`$SHELL -l`, else `/bin/sh -l`; `powershell.exe -NoLogo` on Windows) with reader, writer, and monitor threads; `terminals/replay.rs` retains the newest 10,000 lines within 2 MiB, cut at a newline. The PTY child and its process-group teardown moved from `auth_terminal.rs` to the shared `pty_child.rs`. Busy detection compares the PTY foreground process group with the shell; the command name and current folder come from `/proc` on Linux. On Windows a child process of the shell marks it busy and names the command (PowerShell cmdlets run in-process and count as idle); the folder stays the start folder there, and macOS reads neither yet (TODOs in `shell.rs`). App Server stop closes every Terminal. +2. **Protocol** (done, protocol 2.3). `terminals/open` (Project or Task target, cols, rows → summary and attach), `terminals/attach` (→ a new attach), `terminals/close` (`force` after confirmation; `conflict` while a command runs). The list is the `terminals` subscription scope with `terminalCollectionUpdated` events; each summary carries id, Project, folder, command, status (`idle`, `busy`, `exited`), exit code, and open time. Resize travels on the socket. The folder is always resolved on App Server from the Project root or the Task Workspace. +3. **Byte stream** (done). A WebSocket upgrade whose path ends in `/terminal` reaches `listener/terminal_socket.rs`. The client sends a text `hello { token, cols, rows }` with a single-use attach token valid 30 s; the server answers `ready`, sends the replay as binary frames, then live output. Client binary frames are input; text `resize { cols, rows }` resizes, the latest client winning. Close codes: 1000 the Terminal ended, 4400 protocol violation, 4401 bad token (ask `terminals/attach`), 4408 the viewer fell behind and must reattach. A viewer more than 256 output chunks behind is dropped rather than stalling the shell or other viewers. The server sends a text `ping` every 15 s, busy or quiet, and the client answers `pong`; each side treats 45 s without a message as a lost connection. The pings are text because a browser answers WebSocket control pings itself without telling the page. +4. **Lifetime and shutdown.** Terminals end with App Server (done: `SharedRpcGateway::shutdown` closes them). Desktop Update readiness reports the open-Terminal count, and Settings appends "N terminals will close." to the active-work note (done). Desktop Quit (the macOS menu and the Windows window close) asks "N terminals will close." in a native dialog before its 5 s exit fallback starts (`desktop_quit.rs`), from a count the Frontend pushes as the list changes. It does not ask when Desktop uses another computer's App Server; it still asks when a connected Remote Device would keep this App Server running (TODO in `desktop_quit.rs`). +5. **Frontend** (done). `components/terminals/` holds the page, its parts, the xterm screen, and the Task header button; `intents/terminalIntents.ts` holds open, attach, and close; `services/terminalSockets.ts` carries one viewer's bytes and reattaches with backoff after a lost socket or a socket that never answers `ready` within 10 s. The list is App State from the `terminals` scope. Each App Shell supplies the socket URL through `FrontendShell.terminals`; the Web dev server, the Android gateway, and the Remote Client gateway forward the `/terminal` upgrade, which the attach token alone authorizes. The page opens a Terminal on entry when its Project has none. +6. **VS Code** (done). `FrontendShell.nativeTerminal` posts `shell.terminal.open` with the Task Workspace or Project root; the extension host requires an absolute folder and calls `vscode.window.createTerminal`. It uses an App Shell capability rather than `ShellCapability::OpenTerminal`, because the Frontend, not App Server, chooses the presentation. Clients no longer advertise `openTerminal`; the variant still deserializes for 2.2 clients and goes with the next major protocol version. +7. **Observability** (done). App Server logs `terminal_open_started`/`_completed`, `terminal_attach_issued`, `terminal_closed`, status changes and exits, `terminal_socket_opened`/`_closed`/`_rejected` with byte counts and reason codes, and `terminal_resize_failed`. The Frontend logs `terminal_intent_*` and `terminal_stream_*` with Terminal id, attempt, close code, and duration. No content, commands, paths, tokens, or environment values. + +## Verification targets + +- Create from a Project and from a Task Workspace on Linux, macOS, native Windows, and WSL; the login shell and folder are correct. +- Disconnect and reconnect a Remote Device mid-command: the shell keeps running, replay is bounded, live output resumes. +- Two clients on one Terminal: both see output, both can type, resize follows the most recent active client. +- Close an idle Terminal, a Terminal with a running command (confirmation), and a Terminal whose shell exited. +- App Server stop and Desktop Update readiness with open Terminals show the warning; Terminals do not keep App Server alive. +- A slow viewer on a relayed path does not stall input for another viewer. +- Logs contain the lifecycle events and no Terminal content. +- Shared Frontend at a wide desktop viewport and a narrow phone viewport, including the key row above the real Android keyboard. diff --git a/openaide-rs/android-ssh/Cargo.toml b/openaide-rs/android-ssh/Cargo.toml new file mode 100644 index 00000000..515a24ff --- /dev/null +++ b/openaide-rs/android-ssh/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "openaide-android-ssh" +version = "0.0.0" +edition.workspace = true +license.workspace = true +publish.workspace = true + +# The Mobile App's SSH Computers (ADR-0069): the shared launcher over an +# embedded SSH client, exposed to Kotlin through UniFFI. +[lib] +crate-type = ["cdylib", "lib"] + +[[bin]] +# Generates the Kotlin bindings from the built library. +name = "uniffi-bindgen" +path = "uniffi-bindgen.rs" + +[dependencies] +openaide-remote-client = { workspace = true, default-features = false, features = ["release-archive"] } +russh = { version = "0.64", default-features = false, features = ["ring", "rsa"] } +serde_json.workspace = true +thiserror.workspace = true +tokio.workspace = true +uniffi = { version = "0.32", features = ["cli"] } + +[dev-dependencies] +tempfile.workspace = true diff --git a/openaide-rs/android-ssh/src/lib.rs b/openaide-rs/android-ssh/src/lib.rs new file mode 100644 index 00000000..afd6fe41 --- /dev/null +++ b/openaide-rs/android-ssh/src/lib.rs @@ -0,0 +1,348 @@ +//! SSH Computers for the Mobile App (ADR-0069). +//! +//! The Kotlin shell owns the phone's key (encrypted with Android Keystore), the +//! accepted host key, the release download, and every question to the user. +//! This library owns the SSH protocol and runs the shared [`SshLauncher`] over +//! it, then keeps a loopback forward to the App Server while the shell holds +//! the [`SshComputer`]. Nothing here is persisted, and logs carry only stable +//! names, classes, counts, and durations. + +mod session; +mod transport; + +use std::fs::File; +use std::io::Read; +use std::path::PathBuf; +use std::sync::{Arc, Mutex, OnceLock}; + +use openaide_remote_client::{ + archive, LaunchError, Launched, Logger, ServerRelease, SshLauncher, StartMode, StopOutcome, +}; +use russh::keys::ssh_key::private::{Ed25519Keypair, KeypairData}; +use russh::keys::{HashAlg, PrivateKey, PublicKey}; +use tokio::runtime::Runtime; + +pub use session::Target; +use session::{Connector, Credential}; +use transport::{EmbeddedSsh, Forward}; + +uniffi::setup_scaffolding!(); + +pub(crate) type Log = Logger; + +/// Why an SSH operation failed: the launch stage and a stable class, such as +/// `auth_failed`, `host_key_changed`, or `unreachable`. +#[derive(Debug, thiserror::Error, uniffi::Error)] +pub enum SshError { + #[error("{stage}: {class}")] + Failed { stage: String, class: String }, +} + +impl SshError { + fn new(stage: &str, class: &str) -> Self { + Self::Failed { + stage: stage.to_string(), + class: class.to_string(), + } + } +} + +impl From for SshError { + fn from(_: uniffi::UnexpectedUniFFICallbackError) -> Self { + Self::new("fetch", "shell_callback_failed") + } +} + +/// What the shell provides while an SSH Computer is in use. +#[uniffi::export(with_foreign)] +pub trait SshEvents: Send + Sync { + /// One metadata-only lifecycle event; `fields` is a JSON object. + fn log(&self, event: String, fields: String); + /// The launch reached `stage`: probe, fetch, upload, start, or forward. + fn stage(&self, stage: String); + /// Downloads this app's Linux App Server archive and returns its path. + /// Called only when the computer and the cache both lack the release. + fn release_archive(&self) -> Result; +} + +/// The App Server on the computer, reached through this phone's loopback. +#[derive(Debug, uniffi::Record)] +pub struct SshReady { + /// `http://127.0.0.1:/`, as a Termux handoff line names it. + pub endpoint_url: String, + pub auth_token: String, + pub app_version: String, + pub update_available: bool, + pub keeper_existed: bool, + pub suggest_linger: bool, +} + +#[derive(Debug, uniffi::Enum)] +pub enum SshLaunch { + Ready { + ready: SshReady, + }, + /// An older App Server whose protocol this app cannot speak runs there; + /// only a confirmed replacement can help. + UpdateRequired { + running_version: String, + }, +} + +/// The SHA-256 fingerprint the user compares, such as `SHA256:…`. +#[uniffi::export] +pub fn host_key_fingerprint(host_key: String) -> Result { + let key = PublicKey::from_openssh(&host_key) + .map_err(|_| SshError::new("trust", "host_key_invalid"))?; + Ok(key.fingerprint(HashAlg::Sha256).to_string()) +} + +/// The phone's public key as an `authorized_keys` line. +#[uniffi::export] +pub fn public_key_line(seed: Vec, comment: String) -> Result { + let mut key = private_key(&seed)?; + key.set_comment(comment); + key.public_key() + .to_openssh() + .map_err(|_| SshError::new("key", "key_invalid")) +} + +/// Connects without signing in and returns the computer's host key in +/// OpenSSH form, for the user to accept before anything else is sent. +#[uniffi::export] +pub fn read_host_key(target: Target) -> Result { + let key = runtime() + .block_on(session::read_host_key(&target)) + .map_err(|class| SshError::new("trust", class))?; + key.to_openssh() + .map_err(|_| SshError::new("trust", "host_key_invalid")) +} + +/// Signs in once with `password` and adds `public_key_line` to the user's +/// `authorized_keys`, unless it is there. The password is not kept. +#[uniffi::export] +pub fn authorize_key( + target: Target, + host_key: String, + password: String, + public_key_line: String, + events: Arc, +) -> Result<(), SshError> { + let stage = "authorize"; + if !is_key_line(&public_key_line) { + return Err(SshError::new(stage, "key_invalid")); + } + let host_key = parse_host_key(&host_key)?; + let connector = Arc::new(Connector::new( + target, + host_key, + Credential::Password(password), + logger(events.clone()), + )); + let mut transport = EmbeddedSsh { + runtime: runtime().clone(), + connector: connector.clone(), + log: logger(events), + }; + // One line, no `'` or `!`, so every common login shell passes it to `sh`. + let command = format!( + "sh -c 'umask 077; mkdir -p \"$HOME/.ssh\" && touch \"$HOME/.ssh/authorized_keys\" && {{ grep -qxF \"$1\" \"$HOME/.ssh/authorized_keys\" || printf \"%s\\n\" \"$1\" >> \"$HOME/.ssh/authorized_keys\"; }}' openaide '{public_key_line}'" + ); + let result = openaide_remote_client::SshTransport::run(&mut transport, &command, None); + runtime().block_on(connector.close()); + match result { + Ok(output) if output.success => Ok(()), + Ok(_) => Err(SshError::new(stage, "authorized_keys_unwritable")), + Err(error) => Err(SshError::new(stage, error.class)), + } +} + +/// One SSH Computer while the shell uses it. Dropping or [`disconnect`](Self::disconnect) +/// ends its forward; the keeper on the computer stays by its own rules. +#[derive(uniffi::Object)] +pub struct SshComputer { + connector: Arc, + events: Arc, + release: ServerRelease, + cache: PathBuf, + forward: Mutex>, +} + +#[uniffi::export] +impl SshComputer { + /// `seed` is the phone's ed25519 key; `host_key` the accepted one, in + /// OpenSSH form; `release_sha256` the Linux binary's, built into the app. + #[uniffi::constructor] + pub fn new( + target: Target, + host_key: String, + seed: Vec, + release_version: String, + release_sha256: String, + cache_directory: String, + events: Arc, + ) -> Result, SshError> { + let release = ServerRelease::new(&release_version, &release_sha256) + .ok_or_else(|| SshError::new("fetch", "unavailable_in_build"))?; + let key = Arc::new(private_key(&seed)?); + let connector = Arc::new(Connector::new( + target, + parse_host_key(&host_key)?, + Credential::Key(key), + logger(events.clone()), + )); + Ok(Arc::new(Self { + connector, + events, + release, + cache: PathBuf::from(cache_directory), + forward: Mutex::new(None), + })) + } + + /// Starts or attaches to the App Server and forwards it. `replace` is the + /// user's confirmed update of an older App Server. + pub fn launch(&self, replace: bool, operation_id: String) -> Result { + let events = self.events.clone(); + let launcher = SshLauncher::new( + self.release.clone(), + logger(self.events.clone()), + Box::new(move |stage| events.stage(stage.as_str().to_string())), + ); + let mut transport = self.transport(); + let mode = if replace { + StartMode::Replace + } else { + StartMode::Keep + }; + let mut fetch = || self.open_release(); + match launcher.launch(&mut transport, mode, &mut fetch, &operation_id) { + Ok(Launched::Ready(ready)) => { + if let Ok(mut forward) = self.forward.lock() { + *forward = Some(ready.forward); + } + Ok(SshLaunch::Ready { + ready: SshReady { + endpoint_url: ready.endpoint_url, + auth_token: ready.auth_token, + app_version: ready.app_version, + update_available: ready.update_available, + keeper_existed: ready.keeper_existed, + suggest_linger: ready.suggest_linger, + }, + }) + } + Ok(Launched::UpdateRequired { running_version }) => { + Ok(SshLaunch::UpdateRequired { running_version }) + } + Err(error) => Err(launch_error(&error)), + } + } + + /// Ends the keeper; returns `stopped`, `notRunning`, or `stillRunning`. + pub fn stop(&self, operation_id: String) -> Result { + let launcher = SshLauncher::new( + self.release.clone(), + logger(self.events.clone()), + Box::new(|_| {}), + ); + let outcome = launcher + .stop(&mut self.transport(), &operation_id) + .map_err(|error| launch_error(&error))?; + Ok(match outcome { + StopOutcome::Stopped => "stopped", + StopOutcome::NotRunning => "notRunning", + StopOutcome::StillRunning => "stillRunning", + } + .to_string()) + } + + /// Ends the forward and the session. Not `close`, which the Kotlin bindings + /// already define to free the object. + pub fn disconnect(&self) { + if let Ok(mut forward) = self.forward.lock() { + forward.take(); + } + runtime().block_on(self.connector.close()); + } +} + +impl SshComputer { + fn transport(&self) -> EmbeddedSsh { + EmbeddedSsh { + runtime: runtime().clone(), + connector: self.connector.clone(), + log: logger(self.events.clone()), + } + } + + /// The verified binary from the cache, downloading the archive first when + /// the cache lacks it. + fn open_release(&self) -> Result, &'static str> { + let binary = self + .cache + .join(self.release.version()) + .join("openaide-app-server"); + let expected = self.release.sha256(); + if archive::sha256_file(&binary).ok().as_deref() != Some(expected) { + let path = self + .events + .release_archive() + .map_err(|_| "download_failed")?; + let file = File::open(&path).map_err(|_| "download_failed")?; + let extracted = archive::extract_verified(file, &binary, expected); + let _ = std::fs::remove_file(&path); + extracted?; + } + File::open(&binary) + .map(|file| Box::new(file) as Box) + .map_err(|_| "release_unreadable") + } +} + +fn launch_error(error: &LaunchError) -> SshError { + SshError::new(error.stage().as_str(), error.class()) +} + +fn private_key(seed: &[u8]) -> Result { + let seed: &[u8; 32] = seed + .try_into() + .map_err(|_| SshError::new("key", "key_invalid"))?; + PrivateKey::new(KeypairData::Ed25519(Ed25519Keypair::from_seed(seed)), "") + .map_err(|_| SshError::new("key", "key_invalid")) +} + +fn parse_host_key(text: &str) -> Result { + PublicKey::from_openssh(text).map_err(|_| SshError::new("trust", "host_key_invalid")) +} + +/// An OpenSSH public key line this library made: safe inside `'…'`. +fn is_key_line(line: &str) -> bool { + line.starts_with("ssh-ed25519 ") + && line.len() < 512 + && line + .chars() + .all(|character| character.is_ascii_alphanumeric() || " +/=@._-".contains(character)) +} + +fn logger(events: Arc) -> Logger { + Arc::new(move |event, fields| events.log(event.to_string(), fields.to_string())) +} + +/// One runtime for the process: sessions, forwards, and their timers. +fn runtime() -> &'static Arc { + static RUNTIME: OnceLock> = OnceLock::new(); + RUNTIME.get_or_init(|| { + Arc::new( + tokio::runtime::Builder::new_multi_thread() + .worker_threads(2) + .thread_name("openaide-ssh") + .enable_all() + .build() + .expect("the SSH runtime starts"), + ) + }) +} + +#[cfg(test)] +mod tests; diff --git a/openaide-rs/android-ssh/src/session.rs b/openaide-rs/android-ssh/src/session.rs new file mode 100644 index 00000000..9860e3aa --- /dev/null +++ b/openaide-rs/android-ssh/src/session.rs @@ -0,0 +1,270 @@ +//! One SSH session to an SSH Computer, opened again on demand. +//! +//! Android stops a hidden app's network without telling it, so a session is +//! not kept healthy in the background: whoever needs it next asks +//! [`Connector::session`], which opens a new one when the last has closed. +//! Every session checks the host key against the one the user accepted. + +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use russh::client::{self, AuthResult, Handle, KeyboardInteractiveAuthResponse}; +use russh::keys::{PrivateKey, PrivateKeyWithHashAlg, PublicKey, PublicKeyOrCertificate}; +use russh::MethodKind; +use serde_json::json; + +use crate::Log; + +const CONNECT_TIMEOUT: Duration = Duration::from_secs(20); + +/// Where the computer is, as the user entered it. +#[derive(Debug, Clone, uniffi::Record)] +pub struct Target { + pub host: String, + pub port: u16, + pub user: String, +} + +/// How a session signs in. +#[derive(Clone)] +pub(crate) enum Credential { + Key(Arc), + /// Used once to add the phone's key, then forgotten. + Password(String), +} + +/// Why a session could not be opened, as a stable class. +pub(crate) type Class = &'static str; + +pub(crate) struct Connector { + target: Target, + host_key: PublicKey, + credential: Credential, + log: Log, + session: tokio::sync::Mutex>>>, + opened: Mutex, +} + +impl Connector { + pub(crate) fn new( + target: Target, + host_key: PublicKey, + credential: Credential, + log: Log, + ) -> Self { + Self { + target, + host_key, + credential, + log, + session: tokio::sync::Mutex::new(None), + opened: Mutex::new(0), + } + } + + /// The open session, or a new one when there is none or it closed. + pub(crate) async fn session(&self) -> Result>, Class> { + let mut slot = self.session.lock().await; + if let Some(handle) = slot.as_ref().filter(|handle| !handle.is_closed()) { + return Ok(handle.clone()); + } + let attempt = { + let mut opened = self.opened.lock().map_err(|_| "ssh_failed")?; + *opened += 1; + *opened + }; + let started = Instant::now(); + (self.log)("ssh_session_open_started", json!({ "attempt": attempt })); + let result = open(&self.target, &self.host_key, &self.credential).await; + (self.log)( + "ssh_session_open_completed", + json!({ + "attempt": attempt, + "outcome": if result.is_ok() { "success" } else { "failure" }, + "errorClass": result.as_ref().err(), + "durationMs": started.elapsed().as_millis() as u64, + }), + ); + let handle = Arc::new(result?); + *slot = Some(handle.clone()); + Ok(handle) + } + + /// Ends the session; the next use opens a new one. + pub(crate) async fn close(&self) { + if let Some(handle) = self.session.lock().await.take() { + let _ = handle + .disconnect(russh::Disconnect::ByApplication, "", "en") + .await; + } + } +} + +/// Reads the computer's host key without signing in, for the user to accept. +pub(crate) async fn read_host_key(target: &Target) -> Result { + let seen = Arc::new(Mutex::new(None)); + let handler = PinnedHost { + expected: None, + seen: seen.clone(), + }; + let handle = connect(target, handler).await?; + let _ = handle + .disconnect(russh::Disconnect::ByApplication, "", "en") + .await; + let key = seen.lock().map_err(|_| "ssh_failed")?.take(); + key.ok_or("ssh_failed") +} + +async fn open( + target: &Target, + host_key: &PublicKey, + credential: &Credential, +) -> Result, Class> { + let handler = PinnedHost { + expected: Some(host_key.clone()), + seen: Arc::default(), + }; + let mut handle = connect(target, handler).await?; + let signed_in = tokio::time::timeout(CONNECT_TIMEOUT, sign_in(&mut handle, target, credential)) + .await + .map_err(|_| "unreachable")??; + if signed_in { + Ok(handle) + } else { + Err("auth_failed") + } +} + +async fn connect(target: &Target, handler: PinnedHost) -> Result, Class> { + let mut addresses = tokio::time::timeout( + CONNECT_TIMEOUT, + tokio::net::lookup_host((target.host.as_str(), target.port)), + ) + .await + .map_err(|_| "unreachable")? + .map_err(|_| "host_unknown")?; + let address = addresses.next().ok_or("host_unknown")?; + let config = Arc::new(client::Config { + // Notices a dead connection while a command or forward is in use. + keepalive_interval: Some(Duration::from_secs(15)), + keepalive_max: 3, + nodelay: true, + ..Default::default() + }); + tokio::time::timeout(CONNECT_TIMEOUT, client::connect(config, address, handler)) + .await + .map_err(|_| "unreachable")? + .map_err(|error| classify(&error)) +} + +async fn sign_in( + handle: &mut Handle, + target: &Target, + credential: &Credential, +) -> Result { + match credential { + Credential::Key(key) => { + let key = PrivateKeyWithHashAlg::new(key.clone(), None); + let result = handle + .authenticate_publickey(&target.user, key) + .await + .map_err(|error| classify(&error))?; + Ok(matches!(result, AuthResult::Success)) + } + Credential::Password(password) => { + let result = handle + .authenticate_password(&target.user, password) + .await + .map_err(|error| classify(&error))?; + match result { + AuthResult::Success => Ok(true), + // Many servers take passwords only through PAM's prompts. + AuthResult::Failure { + remaining_methods, .. + } if remaining_methods.contains(&MethodKind::KeyboardInteractive) => { + keyboard_interactive(handle, target, password).await + } + AuthResult::Failure { .. } => Ok(false), + } + } + } +} + +/// Answers every hidden prompt with the password; a visible prompt, such as a +/// one-time code, is not something this flow can answer. +async fn keyboard_interactive( + handle: &mut Handle, + target: &Target, + password: &str, +) -> Result { + let mut response = handle + .authenticate_keyboard_interactive_start(&target.user, None) + .await + .map_err(|error| classify(&error))?; + for _ in 0..4 { + match response { + KeyboardInteractiveAuthResponse::Success => return Ok(true), + KeyboardInteractiveAuthResponse::Failure { .. } => return Ok(false), + KeyboardInteractiveAuthResponse::InfoRequest { prompts, .. } => { + if prompts.iter().any(|prompt| prompt.echo) { + return Err("auth_prompt_unsupported"); + } + let answers = prompts.iter().map(|_| password.to_string()).collect(); + response = handle + .authenticate_keyboard_interactive_respond(answers) + .await + .map_err(|error| classify(&error))?; + } + } + } + Ok(false) +} + +/// Accepts only the expected host key, and remembers the key it was shown. +pub(crate) struct PinnedHost { + expected: Option, + seen: Arc>>, +} + +impl client::Handler for PinnedHost { + type Error = russh::Error; + + async fn check_server_key( + &mut self, + server_key: &PublicKeyOrCertificate, + ) -> Result { + let PublicKeyOrCertificate::PublicKey { key, .. } = server_key else { + return Ok(false); + }; + if let Ok(mut seen) = self.seen.lock() { + *seen = Some(key.clone()); + } + Ok(match &self.expected { + Some(expected) => expected.key_data() == key.key_data(), + None => true, + }) + } +} + +/// Maps a russh failure to a class; its text is never kept. +pub(crate) fn classify(error: &russh::Error) -> Class { + match error { + russh::Error::IO(error) => match error.kind() { + std::io::ErrorKind::ConnectionRefused => "connection_refused", + std::io::ErrorKind::TimedOut + | std::io::ErrorKind::HostUnreachable + | std::io::ErrorKind::NetworkUnreachable => "unreachable", + _ => "connection_lost", + }, + russh::Error::ConnectionTimeout + | russh::Error::KeepaliveTimeout + | russh::Error::InactivityTimeout + | russh::Error::Elapsed(_) => "unreachable", + russh::Error::Disconnect | russh::Error::HUP | russh::Error::SendError => "connection_lost", + russh::Error::ChannelOpenFailure(_) => "forward_failed", + // The handler refused a key other than the accepted one. + russh::Error::UnknownKey => "host_key_changed", + russh::Error::NoCommonAlgo { .. } | russh::Error::UnknownAlgo => "unsupported_algorithm", + _ => "ssh_failed", + } +} diff --git a/openaide-rs/android-ssh/src/tests.rs b/openaide-rs/android-ssh/src/tests.rs new file mode 100644 index 00000000..72909a06 --- /dev/null +++ b/openaide-rs/android-ssh/src/tests.rs @@ -0,0 +1,166 @@ +use super::*; + +const SEED: [u8; 32] = [7; 32]; + +#[test] +fn the_public_key_line_is_an_ed25519_authorized_keys_entry() { + let line = public_key_line(SEED.to_vec(), "openaide-android".into()).unwrap(); + + assert!(line.starts_with("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5")); + assert!(line.ends_with(" openaide-android")); + assert!(is_key_line(&line)); + assert_eq!( + public_key_line(SEED.to_vec(), "openaide-android".into()).unwrap(), + line + ); +} + +#[test] +fn a_key_line_that_could_leave_its_quotes_is_refused() { + assert!(!is_key_line("ssh-ed25519 AAAA x'; rm -rf ~")); + assert!(!is_key_line("ssh-rsa AAAA x")); + assert!(matches!( + public_key_line(vec![1; 31], String::new()), + Err(SshError::Failed { class, .. }) if class == "key_invalid" + )); +} + +#[test] +fn fingerprints_are_openssh_sha256() { + let line = public_key_line(SEED.to_vec(), String::new()).unwrap(); + let fingerprint = host_key_fingerprint(line).unwrap(); + + assert!(fingerprint.starts_with("SHA256:")); + assert_eq!(fingerprint.len(), "SHA256:".len() + 43); + assert!(host_key_fingerprint("not a key".into()).is_err()); +} + +struct PrintedEvents { + archive: Option, +} + +impl SshEvents for PrintedEvents { + fn log(&self, event: String, fields: String) { + println!("{event} {fields}"); + } + + fn stage(&self, _stage: String) {} + + fn release_archive(&self) -> Result { + self.archive + .clone() + .ok_or_else(|| SshError::new("fetch", "release_not_published")) + } +} + +/// The phone's whole flow against a real Linux x64 host: read and accept the +/// host key, add the phone's key with a password once, launch, reach the App +/// Server through the forward, attach again, and stop. Run it by hand with +/// `OPENAIDE_SSH_E2E_HOST`, `_PORT`, `_USER`, `_PASSWORD`, `_ARCHIVE` (a +/// published Linux x64 archive or one packed the same way), `_SHA256` (its +/// binary's), and `_VERSION`, then +/// `cargo test -p openaide-android-ssh against_a_real_ssh_host -- --ignored`. +#[test] +#[ignore = "needs an SSH host; see the doc comment"] +fn against_a_real_ssh_host() { + use std::io::{Read, Write}; + + let variable = |name: &str| { + std::env::var(format!("OPENAIDE_SSH_E2E_{name}")) + .unwrap_or_else(|_| panic!("OPENAIDE_SSH_E2E_{name} is not set")) + }; + let target = Target { + host: variable("HOST"), + port: variable("PORT").parse().unwrap(), + user: variable("USER"), + }; + let seed = [42; 32].to_vec(); + let cache = tempfile::tempdir().unwrap(); + let archive = cache.path().join("download.tar.gz"); + std::fs::copy(variable("ARCHIVE"), &archive).unwrap(); + let events: Arc = Arc::new(PrintedEvents { + archive: Some(archive.to_string_lossy().into_owned()), + }); + + let host_key = read_host_key(target.clone()).unwrap(); + println!( + "host key {}", + host_key_fingerprint(host_key.clone()).unwrap() + ); + let line = public_key_line(seed.clone(), "openaide-android".into()).unwrap(); + authorize_key( + target.clone(), + host_key.clone(), + variable("PASSWORD"), + line, + events.clone(), + ) + .unwrap(); + + let computer = SshComputer::new( + target.clone(), + host_key.clone(), + seed.clone(), + variable("VERSION"), + variable("SHA256"), + cache.path().join("release").to_string_lossy().into_owned(), + events.clone(), + ) + .unwrap(); + let SshLaunch::Ready { ready } = computer.launch(false, "e2e-1".into()).unwrap() else { + panic!("the host runs an incompatible App Server"); + }; + assert!(!ready.keeper_existed); + let address = ready + .endpoint_url + .strip_prefix("http://") + .and_then(|rest| rest.split_once('/')) + .unwrap(); + for _ in 0..2 { + // Each request is its own connection and so its own channel. + let mut stream = std::net::TcpStream::connect(address.0).unwrap(); + write!( + stream, + "GET /{} HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n", + address.1 + ) + .unwrap(); + let mut response = String::new(); + stream.read_to_string(&mut response).unwrap(); + assert!(response.starts_with("HTTP/1.1 "), "{response}"); + } + computer.disconnect(); + + let again = SshComputer::new( + target.clone(), + host_key.clone(), + seed.clone(), + variable("VERSION"), + variable("SHA256"), + cache.path().join("release").to_string_lossy().into_owned(), + events.clone(), + ) + .unwrap(); + let SshLaunch::Ready { ready } = again.launch(false, "e2e-2".into()).unwrap() else { + panic!("the second launch did not attach"); + }; + assert!(ready.keeper_existed); + assert_eq!(again.stop("e2e-3".into()).unwrap(), "stopped"); + again.disconnect(); + + let other_key = public_key_line([1; 32].to_vec(), String::new()).unwrap(); + let wrong = SshComputer::new( + target, + other_key, + seed, + variable("VERSION"), + variable("SHA256"), + cache.path().join("release").to_string_lossy().into_owned(), + events, + ) + .unwrap(); + assert!(matches!( + wrong.launch(false, "e2e-4".into()), + Err(SshError::Failed { class, .. }) if class == "host_key_changed" + )); +} diff --git a/openaide-rs/android-ssh/src/transport.rs b/openaide-rs/android-ssh/src/transport.rs new file mode 100644 index 00000000..30f4ccd0 --- /dev/null +++ b/openaide-rs/android-ssh/src/transport.rs @@ -0,0 +1,185 @@ +//! The launcher's [`SshTransport`] over the embedded client. +//! +//! Commands run on the shared session. The forward listens on this phone's +//! loopback and opens one `direct-tcpip` channel per accepted connection, so a +//! connection made after the session dropped opens a new session first. The +//! gateway sends each WebView request as its own connection, which makes that +//! the reconnect: the next request after a lost network finds a fresh session. + +use std::io::Read; +use std::net::SocketAddr; +use std::sync::Arc; +use std::time::Instant; + +use openaide_remote_client::{RemoteOutput, SshTransport, TransportError}; +use russh::ChannelMsg; +use serde_json::json; +use tokio::runtime::Runtime; +use tokio::task::JoinHandle; + +use crate::session::Connector; +use crate::Log; + +/// Bounds what a launcher command may print; its scripts print a few lines. +const MAX_STDOUT_BYTES: usize = 1024 * 1024; +const UPLOAD_CHUNK_BYTES: usize = 32 * 1024; + +pub(crate) struct EmbeddedSsh { + pub(crate) runtime: Arc, + pub(crate) connector: Arc, + pub(crate) log: Log, +} + +/// Ends the forward's listener when dropped; channels in flight finish. +pub struct Forward { + listener: JoinHandle<()>, +} + +impl Drop for Forward { + fn drop(&mut self) { + self.listener.abort(); + } +} + +impl SshTransport for EmbeddedSsh { + type Forward = Forward; + + fn run( + &mut self, + command: &str, + input: Option<&mut dyn Read>, + ) -> Result { + let connector = self.connector.clone(); + self.runtime + .block_on(run(&connector, command, input)) + .map_err(|class| TransportError { class }) + } + + fn forward(&mut self, remote_port: u16) -> Result<(Forward, SocketAddr), TransportError> { + let listener = self + .runtime + .block_on(tokio::net::TcpListener::bind("127.0.0.1:0")) + .map_err(|_| TransportError { + class: "local_port_unavailable", + })?; + let local = listener.local_addr().map_err(|_| TransportError { + class: "local_port_unavailable", + })?; + let connector = self.connector.clone(); + let log = self.log.clone(); + let accept = self + .runtime + .spawn(accept(listener, connector, log, remote_port)); + Ok((Forward { listener: accept }, local)) + } +} + +async fn run( + connector: &Connector, + command: &str, + input: Option<&mut dyn Read>, +) -> Result { + let session = connector.session().await?; + let mut channel = session + .channel_open_session() + .await + .map_err(|error| crate::session::classify(&error))?; + let lost = |error: russh::Error| crate::session::classify(&error); + channel.exec(true, command).await.map_err(lost)?; + if let Some(input) = input { + // The launcher's input is a local file; reading it here blocks only + // the caller's thread, which is waiting for this command anyway. + let mut buffer = vec![0; UPLOAD_CHUNK_BYTES]; + loop { + let read = input.read(&mut buffer).map_err(|_| "release_unreadable")?; + if read == 0 { + break; + } + channel.data(&buffer[..read]).await.map_err(lost)?; + } + } + channel.eof().await.map_err(lost)?; + let mut stdout = Vec::new(); + let mut status = None; + while let Some(message) = channel.wait().await { + match message { + ChannelMsg::Data { data } => { + if stdout.len() + data.len() > MAX_STDOUT_BYTES { + return Err("output_too_large"); + } + stdout.extend_from_slice(&data); + } + ChannelMsg::ExitStatus { exit_status } => status = Some(exit_status), + ChannelMsg::Failure => return Err("command_refused"), + _ => {} + } + } + // A channel that closes with no exit status means the session ended under it. + let status = status.ok_or("connection_lost")?; + Ok(RemoteOutput { + success: status == 0, + stdout, + }) +} + +async fn accept( + listener: tokio::net::TcpListener, + connector: Arc, + log: Log, + remote_port: u16, +) { + loop { + let Ok((stream, peer)) = listener.accept().await else { + log("ssh_forward_accept_failed", json!({})); + continue; + }; + let connector = connector.clone(); + let log = log.clone(); + tokio::spawn(async move { + let started = Instant::now(); + if let Err(class) = carry(stream, peer, &connector, remote_port).await { + // A healthy forward is quiet; only failures are logged. + log( + "ssh_forward_channel_failed", + json!({ + "errorClass": class, + "durationMs": started.elapsed().as_millis() as u64, + }), + ); + } + }); + } +} + +async fn carry( + mut stream: tokio::net::TcpStream, + peer: SocketAddr, + connector: &Connector, + remote_port: u16, +) -> Result<(), &'static str> { + let open = |session: Arc>| async move { + session + .channel_open_direct_tcpip( + "127.0.0.1", + u32::from(remote_port), + "127.0.0.1", + u32::from(peer.port()), + ) + .await + }; + let session = connector.session().await?; + let channel = match open(session.clone()).await { + Ok(channel) => channel, + // The session ended under this request; the next session is new. + Err(_) if session.is_closed() => open(connector.session().await?) + .await + .map_err(|error| crate::session::classify(&error))?, + // The App Server's port refused the channel, such as after it stopped. + Err(error) => return Err(crate::session::classify(&error)), + }; + let mut remote = channel.into_stream(); + tokio::io::copy_bidirectional(&mut stream, &mut remote) + .await + .map(|_| ()) + .map_err(|_| "connection_lost") +} diff --git a/openaide-rs/android-ssh/uniffi-bindgen.rs b/openaide-rs/android-ssh/uniffi-bindgen.rs new file mode 100644 index 00000000..f6cff6cf --- /dev/null +++ b/openaide-rs/android-ssh/uniffi-bindgen.rs @@ -0,0 +1,3 @@ +fn main() { + uniffi::uniffi_bindgen_main() +} diff --git a/openaide-rs/android-ssh/uniffi.toml b/openaide-rs/android-ssh/uniffi.toml new file mode 100644 index 00000000..a054088f --- /dev/null +++ b/openaide-rs/android-ssh/uniffi.toml @@ -0,0 +1,2 @@ +[bindings.kotlin] +package_name = "io.openaide.android.ssh" diff --git a/openaide-rs/app-server-protocol/src/client.rs b/openaide-rs/app-server-protocol/src/client.rs index fb6180d4..69150b71 100644 --- a/openaide-rs/app-server-protocol/src/client.rs +++ b/openaide-rs/app-server-protocol/src/client.rs @@ -8,7 +8,7 @@ use crate::snapshot::{ClientSnapshot, ProjectCollectionSnapshot, ProtocolVersion /// /// Bump `minor` when adding methods, fields, or events. Bump `major` and reset /// `minor` for any change an older client cannot ignore. -pub const APP_SERVER_PROTOCOL_VERSION: ProtocolVersion = ProtocolVersion { major: 2, minor: 2 }; +pub const APP_SERVER_PROTOCOL_VERSION: ProtocolVersion = ProtocolVersion { major: 2, minor: 3 }; #[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, TS)] #[serde(rename_all = "camelCase")] @@ -85,6 +85,46 @@ pub struct UpdateShutdownAbortParams { #[serde(rename_all = "camelCase")] pub struct UpdateShutdownAbortResult {} +/// Asks what replacing this App Server with another version would affect now. A client +/// that installed a different version shows the answer before the user confirms the +/// replacement (ADR-0069). +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, TS)] +#[serde(rename_all = "camelCase")] +pub struct ClientReplacementImpactParams {} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, TS)] +#[serde(rename_all = "camelCase")] +pub struct ClientReplacementImpactResult { + /// Initialized clients other than the caller, including those inside their reconnect + /// grace, which a replacement would also disconnect. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub other_clients: Vec, + pub active_turns: u32, + pub pending_task_requests: u32, + /// Terminals whose shells a replacement would end (ADR-0071). Absent from App Servers + /// before protocol 2.3. + #[serde(default)] + pub open_terminals: u32, +} + +/// What a client reported about itself in `client/initialize`. Labels for the user, not +/// evidence. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, TS)] +#[serde(rename_all = "camelCase")] +pub struct AttachedClientSummary { + pub kind: ShellKind, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + /// The product version the client reported. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub app_version: Option, + /// Omitted by clients installed together with their App Server. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub protocol_version: Option, + /// The client stopped responding and is inside its reconnect grace. + pub reconnecting: bool, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, TS)] #[serde(rename_all = "camelCase")] pub enum ClientProbeLifecycle { @@ -183,6 +223,10 @@ pub enum ShellKind { /// The App Server's own `remote` command: a same-machine client with no /// window that keeps the App Server running for Remote Devices. Terminal, + /// The App Server's own keeper on an SSH Computer: a same-machine client + /// with no window that holds the App Server while it has work or a recent + /// App Shell, then leaves (ADR-0069). + Keeper, } #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, TS)] @@ -250,6 +294,10 @@ pub enum ShellCapability { RevealFile, ResolveFileReveal, PickLocalFile, + /// Read nowhere and no longer advertised; Terminals are an App Server feature (ADR-0071) + /// and VS Code opens its own through an App Shell capability. Clients up to protocol 2.2 + /// still send it, so it must keep deserializing. + // TODO(protocol-3): remove with the next major protocol version, which drops 2.x clients. OpenTerminal, ReadSecret, WriteSecret, diff --git a/openaide-rs/app-server-protocol/src/events.rs b/openaide-rs/app-server-protocol/src/events.rs index e428a297..902c37db 100644 --- a/openaide-rs/app-server-protocol/src/events.rs +++ b/openaide-rs/app-server-protocol/src/events.rs @@ -130,6 +130,10 @@ pub enum AppServerEventPayload { DeviceCollectionUpdated { devices: crate::devices::DeviceCollectionSnapshot, }, + /// Replaces the open Terminal list, including each Terminal's status. + TerminalCollectionUpdated { + terminals: crate::terminals::TerminalCollectionSnapshot, + }, } #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, TS)] diff --git a/openaide-rs/app-server-protocol/src/lib.rs b/openaide-rs/app-server-protocol/src/lib.rs index bb0c7363..c4f2f2b5 100644 --- a/openaide-rs/app-server-protocol/src/lib.rs +++ b/openaide-rs/app-server-protocol/src/lib.rs @@ -18,6 +18,7 @@ pub mod snapshot; pub mod state; pub mod support; pub mod task; +pub mod terminals; pub mod typescript; pub mod workspace; pub mod worktree; diff --git a/openaide-rs/app-server-protocol/src/methods.rs b/openaide-rs/app-server-protocol/src/methods.rs index 2749e6f5..23fd1b80 100644 --- a/openaide-rs/app-server-protocol/src/methods.rs +++ b/openaide-rs/app-server-protocol/src/methods.rs @@ -29,9 +29,10 @@ use crate::attachment::{ use crate::client::{ ClientCapabilitiesChangedParams, ClientCapabilitiesChangedResult, ClientDetachParams, ClientDetachResult, ClientHeartbeatParams, ClientHeartbeatResult, ClientProbeParams, - ClientProbeResult, InitializeParams, InitializeResult, UpdateShutdownAbortParams, - UpdateShutdownAbortResult, UpdateShutdownCommitParams, UpdateShutdownCommitResult, - UpdateShutdownPrepareParams, UpdateShutdownPrepareResult, + ClientProbeResult, ClientReplacementImpactParams, ClientReplacementImpactResult, + InitializeParams, InitializeResult, UpdateShutdownAbortParams, UpdateShutdownAbortResult, + UpdateShutdownCommitParams, UpdateShutdownCommitResult, UpdateShutdownPrepareParams, + UpdateShutdownPrepareResult, }; use crate::diagnostics::{ RuntimeDiagnosticsParams, RuntimeDiagnosticsResult, SupportExportCreateParams, @@ -142,9 +143,11 @@ macro_rules! protocol_method { // Declared after the macro so the modules can use it. mod devices; mod task_attention; +mod terminals; pub use devices::*; pub use task_attention::*; +pub use terminals::*; protocol_method!( ClientProbe, @@ -194,6 +197,12 @@ protocol_method!( UpdateShutdownAbortParams, UpdateShutdownAbortResult ); +protocol_method!( + ClientReplacementImpact, + CLIENT_REPLACEMENT_IMPACT, + ClientReplacementImpactParams, + ClientReplacementImpactResult +); protocol_method!( PendingRequestResolve, PENDING_REQUEST_RESOLVE, diff --git a/openaide-rs/app-server-protocol/src/methods/names.rs b/openaide-rs/app-server-protocol/src/methods/names.rs index 2a318e46..4abfde79 100644 --- a/openaide-rs/app-server-protocol/src/methods/names.rs +++ b/openaide-rs/app-server-protocol/src/methods/names.rs @@ -10,6 +10,7 @@ pub const CLIENT_DETACH: &str = "client/detach"; pub const CLIENT_UPDATE_SHUTDOWN_PREPARE: &str = "client/updateShutdownPrepare"; pub const CLIENT_UPDATE_SHUTDOWN_COMMIT: &str = "client/updateShutdownCommit"; pub const CLIENT_UPDATE_SHUTDOWN_ABORT: &str = "client/updateShutdownAbort"; +pub const CLIENT_REPLACEMENT_IMPACT: &str = "client/replacementImpact"; pub const PENDING_REQUEST_RESOLVE: &str = "pendingRequest/resolve"; pub const STATE_SUBSCRIBE: &str = "state/subscribe"; pub const STATE_UNSUBSCRIBE: &str = "state/unsubscribe"; @@ -48,6 +49,9 @@ pub const DEVICES_CANCEL_INVITE: &str = "devices/cancelInvite"; pub const DEVICES_PREVIEW_JOIN_REQUEST: &str = "devices/previewJoinRequest"; pub const DEVICES_APPROVE_JOIN_REQUEST: &str = "devices/approveJoinRequest"; pub const DEVICES_REMOVE: &str = "devices/remove"; +pub const TERMINALS_OPEN: &str = "terminals/open"; +pub const TERMINALS_ATTACH: &str = "terminals/attach"; +pub const TERMINALS_CLOSE: &str = "terminals/close"; pub const ATTACHMENT_LIST_ROOTS: &str = "attachment/listRoots"; pub const ATTACHMENT_LIST_DIRECTORY: &str = "attachment/listDirectory"; pub const ATTACHMENT_CREATE_FILE_REFERENCE: &str = "attachment/createFileReference"; @@ -127,6 +131,7 @@ pub const CLIENT_METHODS: &[&str] = &[ CLIENT_UPDATE_SHUTDOWN_PREPARE, CLIENT_UPDATE_SHUTDOWN_COMMIT, CLIENT_UPDATE_SHUTDOWN_ABORT, + CLIENT_REPLACEMENT_IMPACT, PENDING_REQUEST_RESOLVE, STATE_SUBSCRIBE, STATE_UNSUBSCRIBE, @@ -165,6 +170,9 @@ pub const CLIENT_METHODS: &[&str] = &[ DEVICES_PREVIEW_JOIN_REQUEST, DEVICES_APPROVE_JOIN_REQUEST, DEVICES_REMOVE, + TERMINALS_OPEN, + TERMINALS_ATTACH, + TERMINALS_CLOSE, ATTACHMENT_LIST_ROOTS, ATTACHMENT_LIST_DIRECTORY, ATTACHMENT_CREATE_FILE_REFERENCE, diff --git a/openaide-rs/app-server-protocol/src/methods/terminals.rs b/openaide-rs/app-server-protocol/src/methods/terminals.rs new file mode 100644 index 00000000..74a7117a --- /dev/null +++ b/openaide-rs/app-server-protocol/src/methods/terminals.rs @@ -0,0 +1,26 @@ +//! Methods that open, attach to, and close Terminals. + +use super::{ProtocolMethod, TERMINALS_ATTACH, TERMINALS_CLOSE, TERMINALS_OPEN}; +use crate::terminals::{ + TerminalsAttachParams, TerminalsAttachResult, TerminalsCloseParams, TerminalsCloseResult, + TerminalsOpenParams, TerminalsOpenResult, +}; + +protocol_method!( + TerminalsOpen, + TERMINALS_OPEN, + TerminalsOpenParams, + TerminalsOpenResult +); +protocol_method!( + TerminalsAttach, + TERMINALS_ATTACH, + TerminalsAttachParams, + TerminalsAttachResult +); +protocol_method!( + TerminalsClose, + TERMINALS_CLOSE, + TerminalsCloseParams, + TerminalsCloseResult +); diff --git a/openaide-rs/app-server-protocol/src/methods/tests.rs b/openaide-rs/app-server-protocol/src/methods/tests.rs index d681000d..8274dc7b 100644 --- a/openaide-rs/app-server-protocol/src/methods/tests.rs +++ b/openaide-rs/app-server-protocol/src/methods/tests.rs @@ -5,6 +5,8 @@ fn client_initialize_uses_slash_method_namespace() { assert_eq!(ClientProbe::METHOD, "client/probe"); assert_eq!(ClientInitialize::METHOD, "client/initialize"); assert_eq!(ClientDetach::METHOD, "client/detach"); + assert_eq!(TerminalsOpen::METHOD, "terminals/open"); + assert_eq!(ClientReplacementImpact::METHOD, "client/replacementImpact"); assert_eq!( ClientUpdateShutdownPrepare::METHOD, "client/updateShutdownPrepare" diff --git a/openaide-rs/app-server-protocol/src/state.rs b/openaide-rs/app-server-protocol/src/state.rs index 4252f48b..8029e27e 100644 --- a/openaide-rs/app-server-protocol/src/state.rs +++ b/openaide-rs/app-server-protocol/src/state.rs @@ -75,6 +75,8 @@ pub enum SubscriptionScope { }, /// The Remote Devices this App Server trusts. Every client may subscribe. Devices, + /// Every open Terminal on this App Server. Every client may subscribe. + Terminals, } #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, TS)] @@ -119,6 +121,9 @@ pub enum SubscriptionSnapshot { Devices { devices: crate::devices::DeviceCollectionSnapshot, }, + Terminals { + terminals: crate::terminals::TerminalCollectionSnapshot, + }, } #[cfg(test)] diff --git a/openaide-rs/app-server-protocol/src/terminals.rs b/openaide-rs/app-server-protocol/src/terminals.rs new file mode 100644 index 00000000..7c2a8645 --- /dev/null +++ b/openaide-rs/app-server-protocol/src/terminals.rs @@ -0,0 +1,112 @@ +//! Terminals: the user's own shells on the App Server's computer (ADR-0071). +//! +//! The App Server Protocol carries lifecycle and the published Terminal list. Keystrokes and +//! output travel on a dedicated per-Terminal WebSocket that a client opens with the single-use +//! attach token these methods return, so terminal bytes never queue behind other traffic. + +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +use crate::ids::{ProjectId, TaskId}; + +/// Where a new Terminal starts. App Server resolves the folder; clients never send a path. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, TS)] +#[serde( + tag = "kind", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] +pub enum TerminalTarget { + /// The Project root. + Project { project_id: ProjectId }, + /// The Task Workspace, which may be a worktree of the Task's Project. + Task { task_id: TaskId }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, TS)] +#[serde(rename_all = "camelCase")] +pub enum TerminalStatus { + /// The shell is waiting at its prompt. + Idle, + /// A foreground command other than the shell is running. + Busy, + /// The shell ended; the Terminal stays listed until the user closes it. + Exited, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, TS)] +#[serde(rename_all = "camelCase")] +pub struct TerminalSummary { + pub terminal_id: String, + /// The Project the Terminal was opened from, for grouping and counts. + pub project_id: ProjectId, + /// The folder the shell started in, for display. + pub folder: String, + /// The foreground command's name while one runs, when the platform reports it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub command: Option, + pub status: TerminalStatus, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub exit_code: Option, + /// Milliseconds since the Unix epoch; orders tabs identically on every client. + pub opened_at: u64, +} + +/// Every open Terminal on this App Server, oldest first. +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, TS)] +#[serde(rename_all = "camelCase")] +pub struct TerminalCollectionSnapshot { + pub terminals: Vec, +} + +/// Authorizes one WebSocket attach to one Terminal. The token is single-use and short-lived; +/// a reconnecting client asks for a new one with `terminals/attach`. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, TS)] +#[serde(rename_all = "camelCase")] +pub struct TerminalAttach { + pub terminal_id: String, + pub token: String, + /// Path on the App Server's local HTTP origin that accepts the WebSocket upgrade. + pub path: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, TS)] +#[serde(rename_all = "camelCase")] +pub struct TerminalsOpenParams { + pub target: TerminalTarget, + pub cols: u16, + pub rows: u16, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, TS)] +#[serde(rename_all = "camelCase")] +pub struct TerminalsOpenResult { + pub terminal: TerminalSummary, + pub attach: TerminalAttach, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, TS)] +#[serde(rename_all = "camelCase")] +pub struct TerminalsAttachParams { + pub terminal_id: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, TS)] +#[serde(rename_all = "camelCase")] +pub struct TerminalsAttachResult { + pub attach: TerminalAttach, +} + +/// Closing a busy Terminal ends its command, so it fails with `conflict` unless `force` is set +/// after the user confirmed. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, TS)] +#[serde(rename_all = "camelCase")] +pub struct TerminalsCloseParams { + pub terminal_id: String, + #[serde(default)] + pub force: bool, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, TS)] +#[serde(rename_all = "camelCase")] +pub struct TerminalsCloseResult {} diff --git a/openaide-rs/app-server-protocol/src/typescript/declarations.rs b/openaide-rs/app-server-protocol/src/typescript/declarations.rs index 2c1bc681..80297cfc 100644 --- a/openaide-rs/app-server-protocol/src/typescript/declarations.rs +++ b/openaide-rs/app-server-protocol/src/typescript/declarations.rs @@ -36,9 +36,10 @@ use crate::attachment::{ PreSendAttachment, }; use crate::client::{ - ClientCapabilities, ClientCapabilitiesChangedParams, ClientCapabilitiesChangedResult, - ClientDetachParams, ClientDetachResult, ClientHeartbeatParams, ClientHeartbeatResult, - ClientProbeLifecycle, ClientProbeParams, ClientProbeResult, ClientProtocolCapability, + AttachedClientSummary, ClientCapabilities, ClientCapabilitiesChangedParams, + ClientCapabilitiesChangedResult, ClientDetachParams, ClientDetachResult, ClientHeartbeatParams, + ClientHeartbeatResult, ClientProbeLifecycle, ClientProbeParams, ClientProbeResult, + ClientProtocolCapability, ClientReplacementImpactParams, ClientReplacementImpactResult, ClientRole, ClientWorkspaceRoot, InitializeParams, InitializeResult, RequestedSurface, SettingsSection, ShellCapability, ShellDescriptor, ShellKind, UpdateShutdownAbortParams, UpdateShutdownAbortResult, UpdateShutdownBlockedReason, UpdateShutdownCommitParams, @@ -172,6 +173,11 @@ use crate::task::{ TaskTitleSelection, TaskToolImagePreviewParams, TaskToolImagePreviewResult, TerminalOutputSnapshot, ToolDetailSnapshot, ToolImagePreview, WorkspaceFileSearchState, }; +use crate::terminals::{ + TerminalAttach, TerminalCollectionSnapshot, TerminalStatus, TerminalSummary, TerminalTarget, + TerminalsAttachParams, TerminalsAttachResult, TerminalsCloseParams, TerminalsCloseResult, + TerminalsOpenParams, TerminalsOpenResult, +}; use crate::workspace::{ WorkspaceBrowserDirectory, WorkspaceBrowserEntry, WorkspaceBrowserRoot, WorkspaceListDirectoryParams, WorkspaceListDirectoryResult, WorkspaceListRootsParams, @@ -238,6 +244,9 @@ pub(super) fn push_protocol_declarations(output: &mut String, config: &Config) { push_decl::(output, config); push_decl::(output, config); push_decl::(output, config); + push_decl::(output, config); + push_decl::(output, config); + push_decl::(output, config); push_decl::(output, config); push_decl::(output, config); push_decl::(output, config); @@ -378,6 +387,17 @@ pub(super) fn push_protocol_declarations(output: &mut String, config: &Config) { push_decl::(output, config); push_decl::(output, config); push_decl::(output, config); + push_decl::(output, config); + push_decl::(output, config); + push_decl::(output, config); + push_decl::(output, config); + push_decl::(output, config); + push_decl::(output, config); + push_decl::(output, config); + push_decl::(output, config); + push_decl::(output, config); + push_decl::(output, config); + push_decl::(output, config); push_decl::(output, config); push_decl::(output, config); diff --git a/openaide-rs/app-server-protocol/src/typescript/method_constants.rs b/openaide-rs/app-server-protocol/src/typescript/method_constants.rs index 9f74f534..bd92dcd6 100644 --- a/openaide-rs/app-server-protocol/src/typescript/method_constants.rs +++ b/openaide-rs/app-server-protocol/src/typescript/method_constants.rs @@ -8,31 +8,32 @@ use crate::methods::{ ATTACHMENT_CREATE_PASTED_IMAGE, ATTACHMENT_LIST_DIRECTORY, ATTACHMENT_LIST_ROOTS, ATTACHMENT_REFRESH_HANDLES, ATTACHMENT_RELEASE, ATTACHMENT_REVEAL, ATTACHMENT_REVEAL_SENT, CLIENT_CAPABILITIES_CHANGED, CLIENT_DETACH, CLIENT_HEARTBEAT, CLIENT_INITIALIZE, CLIENT_PROBE, - CLIENT_UPDATE_SHUTDOWN_ABORT, CLIENT_UPDATE_SHUTDOWN_COMMIT, CLIENT_UPDATE_SHUTDOWN_PREPARE, - DIAGNOSTICS_CREATE_SUPPORT_EXPORT, DIAGNOSTICS_GET_RUNTIME, DIAGNOSTICS_LIST_SUPPORT_EXPORT, - FILE_VIEWER_OPEN, FILE_VIEWER_OPEN_FROM_HANDLE, FILE_VIEWER_REFRESH, FILE_VIEWER_RELEASE, - MCP_CREATE_SERVER, MCP_DELETE_SERVER, MCP_GET_SERVER_DETAILS, MCP_SET_SERVER_ENABLED, - MCP_UPDATE_SERVER, NATIVE_SESSION_ARCHIVE, NATIVE_SESSION_DELETE, NATIVE_SESSION_FORK, - NATIVE_SESSION_RESTORE, NATIVE_SESSION_SET_PINNED, NATIVE_SESSION_SET_TITLE, - PENDING_REQUEST_RESOLVE, PROJECT_ADD, PROJECT_REFRESH, PROJECT_REMOVE, PROJECT_RENAME, - SETTINGS_GET_AGENT_DETAILS, SETTINGS_GET_MCP_SERVERS, SETTINGS_GET_PREFERENCES, - SETTINGS_GET_RUNTIME, SETTINGS_GET_SKILLS, SETTINGS_GET_SKILL_DETAILS, - SETTINGS_RESET_TASK_HISTORY, SETTINGS_UPDATE_NEW_TASK_DEFAULTS, SETTINGS_UPDATE_PREFERENCES, - SETTINGS_UPDATE_RUNTIME, SHELL_RESOLVE_FILE_REVEAL, STATE_SUBSCRIBE, STATE_UNSUBSCRIBE, - SUPPORT_RECOVER_STUCK_SESSIONS, TASK_ACQUIRE, TASK_ACQUIRE_IN_WORKTREE, - TASK_ADOPT_NATIVE_SESSION, TASK_ARCHIVE, TASK_ARCHIVE_OLDER, TASK_CANCEL, TASK_CHAT_PAGE, - TASK_CLOSE_PLAN, TASK_COMPOSER_HISTORY, TASK_LIST, TASK_MARK_READ, TASK_NAVIGATION_LOAD_MORE, - TASK_NAVIGATION_REFRESH, TASK_OPEN, TASK_QUEUE_APPEND, TASK_QUEUE_MOVE, TASK_QUEUE_REMOVE, - TASK_QUEUE_TAKE, TASK_RELEASE, TASK_RELOAD_NATIVE_SESSION, TASK_RESTORE, TASK_SEARCH_FILES, - TASK_SEND, TASK_SET_CONFIG_OPTION, TASK_SET_PERMISSION_POLICY, TASK_SET_PINNED, TASK_SET_TITLE, - TASK_STOP_BACKGROUND_COMMAND, TASK_STOP_SUBAGENT, TASK_TOOL_IMAGE_PREVIEW, - WORKSPACE_LIST_DIRECTORY, WORKSPACE_LIST_ROOTS, WORKTREE_CREATE, WORKTREE_LINKED_TASKS, - WORKTREE_RECREATE, WORKTREE_REFRESH, WORKTREE_REMOVAL_PREFLIGHT, WORKTREE_REMOVE, - WORKTREE_RENAME, WORKTREE_RESOLVE_FOLDER, + CLIENT_REPLACEMENT_IMPACT, CLIENT_UPDATE_SHUTDOWN_ABORT, CLIENT_UPDATE_SHUTDOWN_COMMIT, + CLIENT_UPDATE_SHUTDOWN_PREPARE, DIAGNOSTICS_CREATE_SUPPORT_EXPORT, DIAGNOSTICS_GET_RUNTIME, + DIAGNOSTICS_LIST_SUPPORT_EXPORT, FILE_VIEWER_OPEN, FILE_VIEWER_OPEN_FROM_HANDLE, + FILE_VIEWER_REFRESH, FILE_VIEWER_RELEASE, MCP_CREATE_SERVER, MCP_DELETE_SERVER, + MCP_GET_SERVER_DETAILS, MCP_SET_SERVER_ENABLED, MCP_UPDATE_SERVER, NATIVE_SESSION_ARCHIVE, + NATIVE_SESSION_DELETE, NATIVE_SESSION_FORK, NATIVE_SESSION_RESTORE, NATIVE_SESSION_SET_PINNED, + NATIVE_SESSION_SET_TITLE, PENDING_REQUEST_RESOLVE, PROJECT_ADD, PROJECT_REFRESH, + PROJECT_REMOVE, PROJECT_RENAME, SETTINGS_GET_AGENT_DETAILS, SETTINGS_GET_MCP_SERVERS, + SETTINGS_GET_PREFERENCES, SETTINGS_GET_RUNTIME, SETTINGS_GET_SKILLS, + SETTINGS_GET_SKILL_DETAILS, SETTINGS_RESET_TASK_HISTORY, SETTINGS_UPDATE_NEW_TASK_DEFAULTS, + SETTINGS_UPDATE_PREFERENCES, SETTINGS_UPDATE_RUNTIME, SHELL_RESOLVE_FILE_REVEAL, + STATE_SUBSCRIBE, STATE_UNSUBSCRIBE, SUPPORT_RECOVER_STUCK_SESSIONS, TASK_ACQUIRE, + TASK_ACQUIRE_IN_WORKTREE, TASK_ADOPT_NATIVE_SESSION, TASK_ARCHIVE, TASK_ARCHIVE_OLDER, + TASK_CANCEL, TASK_CHAT_PAGE, TASK_CLOSE_PLAN, TASK_COMPOSER_HISTORY, TASK_LIST, TASK_MARK_READ, + TASK_NAVIGATION_LOAD_MORE, TASK_NAVIGATION_REFRESH, TASK_OPEN, TASK_QUEUE_APPEND, + TASK_QUEUE_MOVE, TASK_QUEUE_REMOVE, TASK_QUEUE_TAKE, TASK_RELEASE, TASK_RELOAD_NATIVE_SESSION, + TASK_RESTORE, TASK_SEARCH_FILES, TASK_SEND, TASK_SET_CONFIG_OPTION, TASK_SET_PERMISSION_POLICY, + TASK_SET_PINNED, TASK_SET_TITLE, TASK_STOP_BACKGROUND_COMMAND, TASK_STOP_SUBAGENT, + TASK_TOOL_IMAGE_PREVIEW, WORKSPACE_LIST_DIRECTORY, WORKSPACE_LIST_ROOTS, WORKTREE_CREATE, + WORKTREE_LINKED_TASKS, WORKTREE_RECREATE, WORKTREE_REFRESH, WORKTREE_REMOVAL_PREFLIGHT, + WORKTREE_REMOVE, WORKTREE_RENAME, WORKTREE_RESOLVE_FOLDER, }; use crate::methods::{ DEVICES_APPROVE_JOIN_REQUEST, DEVICES_CANCEL_INVITE, DEVICES_CREATE_INVITE, - DEVICES_PREVIEW_JOIN_REQUEST, DEVICES_REMOVE, TASK_ATTENTION_WAIT, + DEVICES_PREVIEW_JOIN_REQUEST, DEVICES_REMOVE, TASK_ATTENTION_WAIT, TERMINALS_ATTACH, + TERMINALS_CLOSE, TERMINALS_OPEN, }; use crate::methods::{ FILE_VIEWER_CHANGES, FILE_VIEWER_DIFF, FILE_VIEWER_LIST_DIRECTORY, FILE_VIEWER_SEARCH, @@ -88,9 +89,13 @@ pub(super) fn push_method_constants(output: &mut String) { CLIENT_UPDATE_SHUTDOWN_COMMIT )); output.push_str(&format!( - "export const CLIENT_UPDATE_SHUTDOWN_ABORT = {:?} as const;\n\n", + "export const CLIENT_UPDATE_SHUTDOWN_ABORT = {:?} as const;\n", CLIENT_UPDATE_SHUTDOWN_ABORT )); + output.push_str(&format!( + "export const CLIENT_REPLACEMENT_IMPACT = {:?} as const;\n\n", + CLIENT_REPLACEMENT_IMPACT + )); output.push_str(&format!( "export const PENDING_REQUEST_RESOLVE = {:?} as const;\n\n", PENDING_REQUEST_RESOLVE @@ -247,6 +252,18 @@ pub(super) fn push_method_constants(output: &mut String) { "export const TASK_ATTENTION_WAIT = {:?} as const;\n", TASK_ATTENTION_WAIT )); + output.push_str(&format!( + "export const TERMINALS_OPEN = {:?} as const;\n", + TERMINALS_OPEN + )); + output.push_str(&format!( + "export const TERMINALS_ATTACH = {:?} as const;\n", + TERMINALS_ATTACH + )); + output.push_str(&format!( + "export const TERMINALS_CLOSE = {:?} as const;\n", + TERMINALS_CLOSE + )); output.push_str(&format!( "export const ATTACHMENT_LIST_ROOTS = {:?} as const;\n", ATTACHMENT_LIST_ROOTS diff --git a/openaide-rs/app-server-protocol/src/typescript/method_maps.rs b/openaide-rs/app-server-protocol/src/typescript/method_maps.rs index efd299d8..20ead69b 100644 --- a/openaide-rs/app-server-protocol/src/typescript/method_maps.rs +++ b/openaide-rs/app-server-protocol/src/typescript/method_maps.rs @@ -11,7 +11,14 @@ pub(super) fn push_method_maps(output: &mut String) { output.len() - ";\n".len(), " | typeof DEVICES_CREATE_INVITE | typeof DEVICES_CANCEL_INVITE | typeof DEVICES_PREVIEW_JOIN_REQUEST | typeof DEVICES_APPROVE_JOIN_REQUEST | typeof DEVICES_REMOVE", ); - output.insert_str(output.len() - ";\n".len(), " | typeof TASK_ATTENTION_WAIT"); + output.insert_str( + output.len() - ";\n".len(), + " | typeof CLIENT_REPLACEMENT_IMPACT", + ); + output.insert_str( + output.len() - ";\n".len(), + " | typeof TASK_ATTENTION_WAIT | typeof TERMINALS_OPEN | typeof TERMINALS_ATTACH | typeof TERMINALS_CLOSE", + ); output.push_str("export type RequestParamsByMethod = {\n"); output.push_str(" [FILE_VIEWER_LIST_DIRECTORY]: ProjectFilesParams;\n"); output.push_str(" [FILE_VIEWER_SEARCH]: ProjectFilesParams;\n"); @@ -26,6 +33,7 @@ pub(super) fn push_method_maps(output: &mut String) { output.push_str(" [CLIENT_UPDATE_SHUTDOWN_PREPARE]: UpdateShutdownPrepareParams;\n"); output.push_str(" [CLIENT_UPDATE_SHUTDOWN_COMMIT]: UpdateShutdownCommitParams;\n"); output.push_str(" [CLIENT_UPDATE_SHUTDOWN_ABORT]: UpdateShutdownAbortParams;\n"); + output.push_str(" [CLIENT_REPLACEMENT_IMPACT]: ClientReplacementImpactParams;\n"); output.push_str(" [PENDING_REQUEST_RESOLVE]: PendingRequestResolveParams;\n"); output.push_str(" [STATE_SUBSCRIBE]: StateSubscribeParams;\n"); output.push_str(" [STATE_UNSUBSCRIBE]: StateUnsubscribeParams;\n"); @@ -65,6 +73,9 @@ pub(super) fn push_method_maps(output: &mut String) { output.push_str(" [DEVICES_APPROVE_JOIN_REQUEST]: DevicesApproveJoinRequestParams;\n"); output.push_str(" [DEVICES_REMOVE]: DevicesRemoveParams;\n"); output.push_str(" [TASK_ATTENTION_WAIT]: TaskAttentionWaitParams;\n"); + output.push_str(" [TERMINALS_OPEN]: TerminalsOpenParams;\n"); + output.push_str(" [TERMINALS_ATTACH]: TerminalsAttachParams;\n"); + output.push_str(" [TERMINALS_CLOSE]: TerminalsCloseParams;\n"); output.push_str(" [ATTACHMENT_LIST_ROOTS]: AttachmentListRootsParams;\n"); output.push_str(" [ATTACHMENT_LIST_DIRECTORY]: AttachmentListDirectoryParams;\n"); output.push_str(" [ATTACHMENT_CREATE_FILE_REFERENCE]: AttachmentCreateFileReferenceParams;\n"); @@ -150,6 +161,7 @@ pub(super) fn push_method_maps(output: &mut String) { output.push_str(" [CLIENT_UPDATE_SHUTDOWN_PREPARE]: UpdateShutdownPrepareResult;\n"); output.push_str(" [CLIENT_UPDATE_SHUTDOWN_COMMIT]: UpdateShutdownCommitResult;\n"); output.push_str(" [CLIENT_UPDATE_SHUTDOWN_ABORT]: UpdateShutdownAbortResult;\n"); + output.push_str(" [CLIENT_REPLACEMENT_IMPACT]: ClientReplacementImpactResult;\n"); output.push_str(" [PENDING_REQUEST_RESOLVE]: PendingRequestResolveResult;\n"); output.push_str(" [STATE_SUBSCRIBE]: StateSubscribeResult;\n"); output.push_str(" [STATE_UNSUBSCRIBE]: StateUnsubscribeResult;\n"); @@ -189,6 +201,9 @@ pub(super) fn push_method_maps(output: &mut String) { output.push_str(" [DEVICES_APPROVE_JOIN_REQUEST]: DevicesApproveJoinRequestResult;\n"); output.push_str(" [DEVICES_REMOVE]: DevicesRemoveResult;\n"); output.push_str(" [TASK_ATTENTION_WAIT]: TaskAttentionWaitResult;\n"); + output.push_str(" [TERMINALS_OPEN]: TerminalsOpenResult;\n"); + output.push_str(" [TERMINALS_ATTACH]: TerminalsAttachResult;\n"); + output.push_str(" [TERMINALS_CLOSE]: TerminalsCloseResult;\n"); output.push_str(" [ATTACHMENT_LIST_ROOTS]: AttachmentListRootsResult;\n"); output.push_str(" [ATTACHMENT_LIST_DIRECTORY]: AttachmentListDirectoryResult;\n"); output.push_str(" [ATTACHMENT_CREATE_FILE_REFERENCE]: AttachmentCreateFileReferenceResult;\n"); @@ -277,6 +292,7 @@ pub(super) fn push_method_maps(output: &mut String) { output.push_str("export type ClientUpdateShutdownPrepareRequest = TypedClientRequest;\n"); output.push_str("export type ClientUpdateShutdownCommitRequest = TypedClientRequest;\n"); output.push_str("export type ClientUpdateShutdownAbortRequest = TypedClientRequest;\n"); + output.push_str("export type ClientReplacementImpactRequest = TypedClientRequest;\n"); output.push_str( "export type ClientCapabilitiesChangedRequest = TypedClientRequest;\n", ); @@ -291,6 +307,7 @@ pub(super) fn push_method_maps(output: &mut String) { output.push_str("export type ClientUpdateShutdownPrepareResponse = ResponseEnvelope;\n"); output.push_str("export type ClientUpdateShutdownCommitResponse = ResponseEnvelope;\n"); output.push_str("export type ClientUpdateShutdownAbortResponse = ResponseEnvelope;\n"); + output.push_str("export type ClientReplacementImpactResponse = ResponseEnvelope;\n"); output .push_str("export type StateSubscribeResponse = ResponseEnvelope;\n"); output.push_str( diff --git a/openaide-rs/app-server/Cargo.toml b/openaide-rs/app-server/Cargo.toml index 5caf6ef8..0798b6ea 100644 --- a/openaide-rs/app-server/Cargo.toml +++ b/openaide-rs/app-server/Cargo.toml @@ -41,11 +41,11 @@ ctrlc = "3.4" qrcode = { version = "0.14", default-features = false } [target.'cfg(unix)'.dependencies] -nix = { version = "0.31", features = ["signal", "hostname"] } +nix = { version = "0.31", features = ["signal", "hostname", "process"] } signal-hook = { version = "0.3", default-features = false } [target.'cfg(windows)'.dependencies] -windows-sys = { version = "0.61", features = ["Win32_Storage_FileSystem"] } +windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_Diagnostics_ToolHelp"] } [target.'cfg(windows)'.build-dependencies] tauri-winres = "=0.3.6" diff --git a/openaide-rs/app-server/src/agent/auth_terminal.rs b/openaide-rs/app-server/src/agent/auth_terminal.rs index e9d18cc7..ce268489 100644 --- a/openaide-rs/app-server/src/agent/auth_terminal.rs +++ b/openaide-rs/app-server/src/agent/auth_terminal.rs @@ -9,12 +9,13 @@ use openaide_app_server_protocol::ids::ClientInstanceId; use openaide_app_server_protocol::server_requests::{ ShellAuthTerminalParams, ShellAuthTerminalResponse, SHELL_AUTH_TERMINAL, }; -use portable_pty::{native_pty_system, CommandBuilder, PtySize}; +use portable_pty::{native_pty_system, CommandBuilder}; use super::{acp_agent_config::AcpAgentConfig, TurnCancellation}; use crate::client_lifecycle::{AppServerTime, Delivery}; use crate::logging; use crate::protocol::errors::RuntimeError; +use crate::pty_child::{pty_size as size, PtyChild}; use crate::server_requests::ServerRequestRuntime; pub trait AuthTerminalRunner: Send + Sync { @@ -102,13 +103,13 @@ impl ClientAuthTerminal { for (key, value) in &config.env { command.env(key, value); } - let mut child = LoginChild { - child: pair - .slave + // Cancellation, disconnect, malformed responses and setup failures drop this child, which + // never leaves a credential-taking process waiting unattended. + let mut child = PtyChild::new( + pair.slave .spawn_command(command) .map_err(|_| terminal_error())?, - running: true, - }; + ); drop(pair.slave); let mut reader = pair .master @@ -145,13 +146,9 @@ impl ClientAuthTerminal { if cancellation.is_cancelled() { return Err(terminal_error()); } - let exited = child.child.try_wait().map_err(|_| terminal_error())?; - if exited.is_some() { - child.kill_group(); - // Never signal this PID after awaiting another browser exchange: it has - // been reaped and the OS may reuse it while the browser is disconnected. - child.running = false; - } + // Once exited, the child is never signalled again, even after awaiting another + // browser exchange: its PID has been reaped and the OS may reuse it. + let exited = child.try_exit().map_err(|_| terminal_error())?; let mut output = Vec::new(); while output.len() < 32768 { let Ok(bytes) = receive.try_recv() else { break }; @@ -231,44 +228,6 @@ fn terminal_error() -> RuntimeError { RuntimeError::NotReady("Agent authentication terminal ended without successful sign-in".into()) } -fn size(cols: u16, rows: u16) -> PtySize { - PtySize { - cols, - rows, - pixel_width: 0, - pixel_height: 0, - } -} - -/// Teardown is unconditional: cancellation, disconnect, malformed responses and spawn/setup -/// failures must never leave a credential-taking process waiting unattended. -struct LoginChild { - child: Box, - running: bool, -} -impl LoginChild { - fn kill_group(&self) { - #[cfg(unix)] - if let Some(pid) = self.child.process_id() { - let _ = nix::sys::signal::killpg( - nix::unistd::Pid::from_raw(pid as i32), - nix::sys::signal::Signal::SIGKILL, - ); - } - } -} -impl Drop for LoginChild { - fn drop(&mut self) { - // PTY launch creates a fresh session/process group. Kill that owned group too: - // package launchers can exit while their credential-taking child stays alive. - if self.running { - self.kill_group(); - let _ = self.child.kill(); - let _ = self.child.wait(); - } - } -} - #[cfg(all(test, unix))] #[path = "auth_terminal_tests.rs"] mod tests; diff --git a/openaide-rs/app-server/src/app_server_client/launch_handoff.rs b/openaide-rs/app-server/src/app_server_client/launch_handoff.rs index 010122c0..d41d9431 100644 --- a/openaide-rs/app-server/src/app_server_client/launch_handoff.rs +++ b/openaide-rs/app-server/src/app_server_client/launch_handoff.rs @@ -54,6 +54,17 @@ impl LaunchWaiter for SleepLaunchWaiter { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct LaunchHandoffPolicy { pub max_wait_attempts: usize, + pub incompatible: IncompatibleServerPolicy, +} + +/// What to do with a running App Server of another version for the same state root. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum IncompatibleServerPolicy { + /// Ask it to stop and start this version. Same-machine App Shells are installed + /// with their App Server, so the newest one installed wins. + Replace, + /// Return it untouched, for a caller that must ask the user first (ADR-0069). + Report, } impl Default for LaunchHandoffPolicy { @@ -62,6 +73,7 @@ impl Default for LaunchHandoffPolicy { // Startup and authenticated replacement share one bounded 50-second // window, below the shell adapters' final 60-second safety ceiling. max_wait_attempts: 200, + incompatible: IncompatibleServerPolicy::Replace, } } } @@ -100,6 +112,11 @@ impl AttachOrLaunchHandoff { AttachOrLaunchRunResult::Fail { reason } => { return Ok(LaunchHandoffResult::Fail { reason }); } + AttachOrLaunchRunResult::ReplaceIncompatible { target, reason } + if self.policy.incompatible == IncompatibleServerPolicy::Report => + { + return Ok(LaunchHandoffResult::Incompatible { target, reason }); + } AttachOrLaunchRunResult::ReplaceIncompatible { target, reason } => { let replacement = request_local_http_replacement(&target)?; let max_wait_attempts = match replacement { @@ -132,9 +149,21 @@ impl AttachOrLaunchHandoff { #[derive(Debug)] pub enum LaunchHandoffResult { - AttachExisting { target: EndpointTarget }, - LaunchNew { lock: RuntimeLock }, - Fail { reason: AttachOrLaunchFailure }, + AttachExisting { + target: EndpointTarget, + }, + LaunchNew { + lock: RuntimeLock, + }, + Fail { + reason: AttachOrLaunchFailure, + }, + /// Only under [`IncompatibleServerPolicy::Report`]: a live App Server of another + /// version, left running. + Incompatible { + target: EndpointTarget, + reason: AttachOrLaunchFailure, + }, } #[derive(Debug, Error)] diff --git a/openaide-rs/app-server/src/app_server_client/launch_handoff_tests.rs b/openaide-rs/app-server/src/app_server_client/launch_handoff_tests.rs index 665e533c..a037d917 100644 --- a/openaide-rs/app-server/src/app_server_client/launch_handoff_tests.rs +++ b/openaide-rs/app-server/src/app_server_client/launch_handoff_tests.rs @@ -64,6 +64,41 @@ fn incompatible_server_with_replacement_endpoint_stops_then_launches_replacement assert_eq!(waiter.calls, 1); } +#[test] +fn reporting_policy_returns_an_incompatible_server_without_asking_it_to_stop() { + let fixture = Fixture::with_policy(LaunchHandoffPolicy { + max_wait_attempts: 3, + incompatible: IncompatibleServerPolicy::Report, + }); + let dispatcher = ProtocolEdgeStdioDispatcher::new_for_test(fixture.state_root.clone()); + let _published = crate::app_server_process::publish_local_http_probe_endpoint( + dispatcher.shared_gateway(), + &fixture.state_root, + fixture.runtime_dir.path(), + ) + .unwrap(); + let mut waiter = CountingWaiter::default(); + + let result = fixture + .run_requiring_app_version("next-release", &mut waiter) + .unwrap(); + + assert!(matches!( + result, + LaunchHandoffResult::Incompatible { + ref target, + reason: AttachOrLaunchFailure::IncompatibleApp, + } if target.app_version == env!("CARGO_PKG_VERSION") + )); + assert_eq!(waiter.calls, 0); + let record = fixture + .endpoint_records + .read(fixture.state_root.fingerprint()) + .unwrap() + .expect("the incompatible App Server keeps its endpoint"); + assert_eq!(record.status, RuntimeEndpointRecordStatus::Running); +} + #[test] fn legacy_release_without_replacement_endpoint_waits_for_liveness_shutdown() { let fixture = Fixture::new(); @@ -148,6 +183,7 @@ fn unreachable_endpoint_is_preserved_until_launch_owner_publishes_replacement() fn busy_launch_lock_is_bounded_by_policy() { let fixture = Fixture::with_policy(LaunchHandoffPolicy { max_wait_attempts: 2, + ..Default::default() }); let _held_lock = acquire_launch_lock(&fixture); let mut waiter = CountingWaiter::default(); @@ -174,6 +210,7 @@ impl Fixture { fn new() -> Self { Self::with_policy(LaunchHandoffPolicy { max_wait_attempts: 3, + ..Default::default() }) } diff --git a/openaide-rs/app-server/src/app_server_handoff.rs b/openaide-rs/app-server/src/app_server_handoff.rs index 5e34da3d..ed589489 100644 --- a/openaide-rs/app-server/src/app_server_handoff.rs +++ b/openaide-rs/app-server/src/app_server_handoff.rs @@ -1,7 +1,8 @@ use std::path::{Path, PathBuf}; use openaide_app_server::app_server_client::launch_handoff::{ - AttachOrLaunchHandoff, LaunchHandoffResult, SleepLaunchWaiter, + AttachOrLaunchHandoff, IncompatibleServerPolicy, LaunchHandoffPolicy, LaunchHandoffResult, + SleepLaunchWaiter, }; use openaide_app_server::app_server_client::runner::{ AttachOrLaunchRequirements, AttachOrLaunchRunner, @@ -25,6 +26,9 @@ pub struct LocalHttpHandoffConnection { kind: &'static str, endpoint_url: String, auth_token: String, + /// Identifies the App Server process; not part of the printed handoff line. + #[serde(skip)] + server_id: String, } impl From for LocalHttpHandoffConnection { @@ -33,6 +37,7 @@ impl From for LocalHttpHandoffConnection { kind: "localHttp", endpoint_url: connection.endpoint_url, auth_token: connection.auth_token, + server_id: String::new(), } } } @@ -45,6 +50,23 @@ impl LocalHttpHandoffConnection { pub fn auth_token(&self) -> &str { &self.auth_token } + + pub fn server_id(&self) -> &str { + &self.server_id + } +} + +/// A live App Server of another version for this state root, left running. +pub struct IncompatibleServer { + pub connection: LocalHttpHandoffConnection, + pub app_version: String, + pub protocol_version: String, +} + +pub enum ReportingHandoff { + Attached(LocalHttpHandoffConnection), + LaunchHere(RuntimeLock), + Incompatible(IncompatibleServer), } pub fn attach_or_launch(state_root: &StateRoot, runtime_root: &Path) -> HandoffStart { @@ -71,6 +93,10 @@ pub fn attach_or_launch(state_root: &StateRoot, runtime_root: &Path) -> HandoffS })) } Ok(LaunchHandoffResult::LaunchNew { lock }) => HandoffStart::LaunchHere(lock), + Ok(LaunchHandoffResult::Incompatible { reason, .. }) => { + eprintln!("OpenAIDE App Server handoff left an incompatible server: {reason:?}"); + std::process::exit(1); + } Ok(LaunchHandoffResult::Fail { reason }) => { eprintln!("OpenAIDE App Server handoff failed: {reason:?}"); std::process::exit(1); @@ -82,6 +108,51 @@ pub fn attach_or_launch(state_root: &StateRoot, runtime_root: &Path) -> HandoffS } } +/// Like [`attach_or_launch`], but a running App Server of another version is +/// returned instead of replaced, and failures are returned as a stable class. +pub fn attach_or_launch_reporting( + state_root: &StateRoot, + runtime_root: &Path, +) -> Result { + let runner = AttachOrLaunchRunner::new( + EndpointRecordStore::new(runtime_root), + launch_lock_path(runtime_root, state_root.fingerprint()), + ); + let handoff = AttachOrLaunchHandoff::new( + runner, + LaunchHandoffPolicy { + incompatible: IncompatibleServerPolicy::Report, + ..Default::default() + }, + ); + let requirements = AttachOrLaunchRequirements { + required_protocol_version: APP_SERVER_PROTOCOL_VERSION.to_string(), + required_app_version: env!("CARGO_PKG_VERSION").to_string(), + }; + let mut waiter = SleepLaunchWaiter::default(); + match handoff.run( + state_root.fingerprint(), + &requirements, + StorageWriterState::Available, + &mut waiter, + ) { + Ok(LaunchHandoffResult::AttachExisting { target }) => connection_from_target(&target) + .map(ReportingHandoff::Attached) + .ok_or("no_local_http_endpoint"), + Ok(LaunchHandoffResult::LaunchNew { lock }) => Ok(ReportingHandoff::LaunchHere(lock)), + Ok(LaunchHandoffResult::Incompatible { target, .. }) => { + let connection = connection_from_target(&target).ok_or("no_local_http_endpoint")?; + Ok(ReportingHandoff::Incompatible(IncompatibleServer { + connection, + app_version: target.app_version, + protocol_version: target.protocol_version, + })) + } + Ok(LaunchHandoffResult::Fail { .. }) => Err("handoff_refused"), + Err(_) => Err("handoff_failed"), + } +} + pub fn print_connection_or_exit(connection: &LocalHttpHandoffConnection) { match serde_json::to_string(connection) { Ok(line) => println!("{line}"), @@ -101,6 +172,7 @@ fn connection_from_target(target: &EndpointTarget) -> Option, reconnect_expires_at: Option, liveness_expires_at: AppServerTime, } @@ -179,6 +183,7 @@ impl ClientHub { now: AppServerTime, ) -> InitializeClientOutcome { self.has_ever_initialized = true; + let protocol_version = params.protocol_version; let context = ClientContext { client_instance_id: params.client_instance_id, connection_id: connection_id.clone(), @@ -196,6 +201,7 @@ impl ClientHub { context.client_instance_id.clone(), ClientRecord { context: context.clone(), + protocol_version, reconnect_expires_at: None, liveness_expires_at: AppServerTime( now.0 + self.liveness_policy.heartbeat_timeout_ms, @@ -368,6 +374,30 @@ impl ClientHub { .map(|(_, record)| &record.context) } + /// Describes every initialized client except `client_instance_id`, reconnecting ones + /// included, in a stable order. Replacing the App Server disconnects all of them. + pub fn other_client_summaries( + &self, + client_instance_id: &ClientInstanceId, + ) -> Vec { + let mut others = self + .clients + .iter() + .filter(|(candidate, _)| *candidate != client_instance_id) + .collect::>(); + others.sort_by(|(left, _), (right, _)| left.as_str().cmp(right.as_str())); + others + .into_iter() + .map(|(_, record)| AttachedClientSummary { + kind: record.context.shell.kind, + name: record.context.shell.name.clone(), + app_version: record.context.shell.version.clone(), + protocol_version: record.protocol_version, + reconnecting: record.reconnect_expires_at.is_some(), + }) + .collect() + } + pub fn has_ever_initialized_clients(&self) -> bool { self.has_ever_initialized } diff --git a/openaide-rs/app-server/src/keeper_command.rs b/openaide-rs/app-server/src/keeper_command.rs new file mode 100644 index 00000000..afa0f2eb --- /dev/null +++ b/openaide-rs/app-server/src/keeper_command.rs @@ -0,0 +1,604 @@ +//! `openaide-app-server keep`: the keeper an App Shell starts over SSH on an SSH +//! Computer (ADR-0069). +//! +//! `keep start` runs in the SSH session and returns at once. It starts `keep run` +//! in a session of its own, relays the one line `keep run` prints about the App +//! Server, and exits. `keep run` then stays as a windowless client of kind +//! `keeper` and holds the App Server while [`policy`] says so. The App Server's +//! lifetime rule does not change: once the keeper and every other client have +//! left, it stops. +//! +//! The printed line is the launcher's only interface: one JSON object whose +//! `outcome` is `started`, `attached`, `joined`, `mismatch`, or `failed`. + +use std::io::{BufRead, BufReader, Write}; +use std::path::Path; +use std::process::{Command, Stdio}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use openaide_app_server::logging; +use openaide_app_server::storage_runtime::StateRoot; +use openaide_app_server_protocol::client::{ + ClientReplacementImpactResult, ShellKind, APP_SERVER_PROTOCOL_VERSION, +}; +use openaide_app_server_protocol::methods::CLIENT_REPLACEMENT_IMPACT; +use serde::Serialize; +use serde_json::json; + +use crate::app_server_handoff::{self, LocalHttpHandoffConnection, ReportingHandoff}; +use crate::remote_command::launch; +use crate::remote_command::local_client::{ClientError, LocalClient}; + +mod lease; +mod policy; + +use lease::{LeaseAttempt, LeaseHolder}; +use policy::{KeeperClock, KeeperPolicy, Observation, Verdict}; + +const TICK: Duration = Duration::from_millis(100); +/// Matches the `remote` command: well inside the App Server's liveness window. +const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(2); +/// The policy counts in minutes and hours; reading more often adds nothing. +const OBSERVE_INTERVAL: Duration = Duration::from_secs(30); +const MAX_MISSED_HEARTBEATS: u32 = 5; +/// How long a replaced App Server's keeper gets to leave before the new one gives up. +const LEASE_TAKEOVER_WAIT: Duration = Duration::from_secs(15); +const STOP_WAIT: Duration = Duration::from_secs(10); + +const USAGE: &str = "\ +Usage (run by an OpenAIDE App Shell over SSH): + openaide-app-server keep start Keep an App Server of this version running; never replace another version + openaide-app-server keep start --replace Replace an App Server of another version, then keep it + openaide-app-server keep start --join Keep whatever App Server runs, whatever its version + openaide-app-server keep stop Stop the keeper; the App Server stops once its last client leaves +"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Mode { + /// Attach to or start this version; report another version untouched. + Own, + Replace, + Join, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Action { + Start(Mode), + Run(Mode), + Stop, + Help, +} + +pub(crate) fn parse(arguments: &[String]) -> Result { + let words: Vec<&str> = arguments.iter().map(String::as_str).collect(); + let mode = |rest: &[&str]| match rest { + [] => Ok(Mode::Own), + ["--replace"] => Ok(Mode::Replace), + ["--join"] => Ok(Mode::Join), + _ => Err(format!("Unexpected \"{}\".", rest.join(" "))), + }; + match words.split_first() { + Some((&"start", rest)) => mode(rest).map(Action::Start), + Some((&"run", rest)) => mode(rest).map(Action::Run), + Some((&"stop", [])) => Ok(Action::Stop), + Some((&("help" | "--help" | "-h"), _)) | None => Ok(Action::Help), + Some((word, _)) => Err(format!("Unknown command \"{word}\".")), + } +} + +/// What `keep start` prints. Tokens and addresses are for the launcher only and +/// are never logged. +#[derive(Debug, Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct Report { + pub(crate) outcome: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) keeper: Option<&'static str>, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) endpoint_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) auth_token: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) app_version: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) protocol_version: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) error: Option<&'static str>, +} + +impl Report { + fn failed(error: &'static str) -> Self { + Self { + outcome: "failed", + error: Some(error), + ..Self::default() + } + } + + fn connected( + outcome: &'static str, + connection: &LocalHttpHandoffConnection, + app_version: String, + protocol_version: String, + ) -> Self { + Self { + outcome, + endpoint_url: Some(connection.endpoint_url().to_string()), + auth_token: Some(connection.auth_token().to_string()), + app_version: Some(app_version), + protocol_version: Some(protocol_version), + ..Self::default() + } + } +} + +pub(crate) fn run(arguments: &[String], state_root: &StateRoot, runtime_root: &Path) -> i32 { + let action = match parse(arguments) { + Ok(action) => action, + Err(problem) => { + eprintln!("{problem}\n\n{USAGE}"); + return 2; + } + }; + match action { + Action::Help => { + print!("{USAGE}"); + 0 + } + Action::Start(mode) => start(mode), + Action::Stop => stop(state_root, runtime_root), + Action::Run(mode) => keep(mode, state_root, runtime_root), + } +} + +/// Starts `keep run` outside this SSH session and relays its one line. +fn start(mode: Mode) -> i32 { + let started = Instant::now(); + logging::info("keeper_start_started", json!({ "mode": mode_class(mode) })); + let relayed = spawn_keeper(mode).and_then(|line| { + println!("{line}"); + std::io::stdout().flush().map_err(|_| "output_closed") + }); + let outcome = relayed.err().unwrap_or("relayed"); + logging::info( + "keeper_start_completed", + json!({ "mode": mode_class(mode), "outcome": outcome, "duration_ms": elapsed_ms(started) }), + ); + if outcome == "relayed" { + 0 + } else { + println!( + "{}", + serde_json::to_string(&Report::failed(outcome)).unwrap_or_default() + ); + 1 + } +} + +fn spawn_keeper(mode: Mode) -> Result { + let executable = std::env::current_exe().map_err(|_| "spawn_failed")?; + let mut command = Command::new(executable); + command.arg("keep").arg("run"); + match mode { + Mode::Own => {} + Mode::Replace => { + command.arg("--replace"); + } + Mode::Join => { + command.arg("--join"); + } + } + command + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()); + new_session(&mut command); + let mut child = command.spawn().map_err(|_| "spawn_failed")?; + let stdout = child.stdout.take().ok_or("no_report")?; + let mut line = String::new(); + BufReader::new(stdout) + .read_line(&mut line) + .map_err(|_| "no_report")?; + let line = line.trim().to_string(); + if line.is_empty() { + return Err("no_report"); + } + // The keeper outlives this command; nothing waits for it. + drop(child); + Ok(line) +} + +/// The keeper must survive the SSH session that started it. +#[cfg(unix)] +fn new_session(command: &mut Command) { + use std::os::unix::process::CommandExt; + // SAFETY: `setsid` is async-signal-safe and touches no memory of the parent. + unsafe { + command.pre_exec(|| { + nix::unistd::setsid() + .map(|_| ()) + .map_err(std::io::Error::from) + }); + } +} + +#[cfg(not(unix))] +fn new_session(_command: &mut Command) {} + +/// The detached keeper: resolves the App Server, takes the lease, reports, keeps. +fn keep(mode: Mode, state_root: &StateRoot, runtime_root: &Path) -> i32 { + let started = Instant::now(); + let resolved = resolve(mode, state_root, runtime_root); + let (outcome, connection, app_version, protocol_version, _launcher) = match resolved { + Ok(Resolved::Keep { + outcome, + connection, + app_version, + protocol_version, + launcher, + }) => (outcome, connection, app_version, protocol_version, launcher), + Ok(Resolved::Mismatch(report)) => { + logging::info( + "keeper_not_started", + json!({ "mode": mode_class(mode), "outcome": report.outcome, "duration_ms": elapsed_ms(started) }), + ); + print_report(&report); + return 0; + } + Err(error) => { + let report = Report::failed(error); + logging::info( + "keeper_not_started", + json!({ "mode": mode_class(mode), "outcome": report.outcome, "error": report.error, "duration_ms": elapsed_ms(started) }), + ); + print_report(&report); + return 0; + } + }; + + let path = lease::lease_path(runtime_root, state_root.fingerprint().as_str()); + let holder = LeaseHolder { + pid: std::process::id(), + server_id: connection.server_id().to_string(), + }; + let lease = match take_lease(&path, &holder) { + Ok(Some(lease)) => lease, + Ok(None) => { + let mut report = Report::connected(outcome, &connection, app_version, protocol_version); + report.keeper = Some("existing"); + logging::info( + "keeper_already_running", + json!({ "outcome": outcome, "duration_ms": elapsed_ms(started) }), + ); + print_report(&report); + return 0; + } + Err(error) => { + print_report(&Report::failed(error)); + return 1; + } + }; + + let mut client = match LocalClient::with_kind( + ShellKind::Keeper, + connection.endpoint_url(), + connection.auth_token(), + ) + .and_then(|mut client| client.initialize().map(|()| client)) + { + Ok(client) => client, + Err(error) => { + print_report(&Report::failed(error.class())); + return 1; + } + }; + let mut report = Report::connected(outcome, &connection, app_version, protocol_version); + report.keeper = Some("new"); + print_report(&report); + logging::info( + "keeper_started", + json!({ "mode": mode_class(mode), "outcome": outcome, "duration_ms": elapsed_ms(started) }), + ); + let ended = hold(&mut client, KeeperPolicy::default()); + let _ = client.detach(); + drop(lease); + logging::info( + "keeper_ended", + json!({ "reason": ended, "held_ms": elapsed_ms(started) }), + ); + 0 +} + +enum Resolved { + Keep { + outcome: &'static str, + connection: LocalHttpHandoffConnection, + app_version: String, + protocol_version: String, + /// Holds the output pipe of an App Server this keeper started. + launcher: Option, + }, + Mismatch(Report), +} + +fn own_versions() -> (String, String) { + ( + env!("CARGO_PKG_VERSION").to_string(), + APP_SERVER_PROTOCOL_VERSION.to_string(), + ) +} + +fn resolve( + mode: Mode, + state_root: &StateRoot, + runtime_root: &Path, +) -> Result { + if mode == Mode::Replace { + // The ordinary App Shell path: attach to this version, or ask another one + // to stop and start this one. The user has confirmed. + let launched = + launch::attach_or_start(state_root, runtime_root).map_err(|error| error.class())?; + return keep_own(launched, state_root, runtime_root); + } + match app_server_handoff::attach_or_launch_reporting(state_root, runtime_root)? { + ReportingHandoff::Attached(connection) => { + let (app_version, protocol_version) = own_versions(); + Ok(Resolved::Keep { + outcome: "attached", + connection, + app_version, + protocol_version, + launcher: None, + }) + } + ReportingHandoff::LaunchHere(lock) if mode == Mode::Own => { + // The started process takes the launch itself. + drop(lock); + let launched = launch::start().map_err(|error| error.class())?; + keep_own(launched, state_root, runtime_root) + } + ReportingHandoff::LaunchHere(_lock) => Err("not_running"), + ReportingHandoff::Incompatible(server) if mode == Mode::Join => Ok(Resolved::Keep { + outcome: "joined", + connection: server.connection, + app_version: server.app_version, + protocol_version: server.protocol_version, + launcher: None, + }), + ReportingHandoff::Incompatible(server) => Ok(Resolved::Mismatch(Report::connected( + "mismatch", + &server.connection, + server.app_version, + server.protocol_version, + ))), + } +} + +/// An App Server of this version now runs; reading its endpoint record again +/// gives the server id the lease names. +fn keep_own( + launched: launch::Connection, + state_root: &StateRoot, + runtime_root: &Path, +) -> Result { + let outcome = if launched.started { + "started" + } else { + "attached" + }; + match app_server_handoff::attach_or_launch_reporting(state_root, runtime_root)? { + ReportingHandoff::Attached(connection) => { + let (app_version, protocol_version) = own_versions(); + Ok(Resolved::Keep { + outcome, + connection, + app_version, + protocol_version, + launcher: Some(launched), + }) + } + _ => Err("launch_unconfirmed"), + } +} + +/// `Ok(None)` when a keeper already keeps this App Server. +fn take_lease(path: &Path, holder: &LeaseHolder) -> Result, &'static str> { + let deadline = Instant::now() + LEASE_TAKEOVER_WAIT; + loop { + match lease::try_acquire(path, holder).map_err(|_| "lease_failed")? { + LeaseAttempt::Acquired(lease) => return Ok(Some(lease)), + LeaseAttempt::Held(Some(other)) if other.server_id == holder.server_id => { + return Ok(None) + } + // The keeper of a replaced App Server leaves once that server is gone. + LeaseAttempt::Held(other) => { + if let Some(other) = other { + terminate(other.pid); + } + if Instant::now() >= deadline { + return Err("keeper_busy"); + } + std::thread::sleep(Duration::from_millis(250)); + } + } + } +} + +fn hold(client: &mut LocalClient, policy: KeeperPolicy) -> &'static str { + let terminated = termination_flag(); + let mut clock = KeeperClock::new(policy, Instant::now()); + let mut last_heartbeat = Instant::now(); + let mut last_observation: Option = None; + let mut last_verdict: Option = None; + let mut missed = 0; + loop { + std::thread::sleep(TICK); + if terminated.load(Ordering::SeqCst) { + return "stop_requested"; + } + if last_heartbeat.elapsed() >= HEARTBEAT_INTERVAL { + last_heartbeat = Instant::now(); + match client.heartbeat() { + Ok(_) => missed = 0, + // The App Server forgot this client, as after the computer slept + // past the liveness window. + Err(ClientError::Refused { code, .. }) => { + logging::warn("keeper_rejoining", json!({ "error": code })); + if client.initialize().is_err() { + return "app_server_gone"; + } + } + Err(error) => { + missed += 1; + logging::warn( + "keeper_heartbeat_missed", + json!({ "attempt": missed, "error": error.class() }), + ); + if missed >= MAX_MISSED_HEARTBEATS { + return "app_server_gone"; + } + } + } + } + if last_observation.is_some_and(|at| at.elapsed() < OBSERVE_INTERVAL) { + continue; + } + last_observation = Some(Instant::now()); + let observation = match observe(client) { + Ok(observation) => observation, + // An App Server too old to say what it is doing cannot be judged, and + // holding it forever would be worse than leaving. + Err(ClientError::Refused { .. }) => return "impact_unsupported", + Err(error) => { + logging::warn("keeper_observe_failed", json!({ "error": error.class() })); + continue; + } + }; + let verdict = clock.observe(&observation, Instant::now()); + if last_verdict != Some(verdict) { + log_verdict(verdict, &observation); + last_verdict = Some(verdict); + } + if let Verdict::Leave(reason) = verdict { + return reason.class(); + } + } +} + +fn observe(client: &mut LocalClient) -> Result { + let answer = client.request(CLIENT_REPLACEMENT_IMPACT, json!({}))?; + let impact: ClientReplacementImpactResult = + serde_json::from_value(answer.result).map_err(|_| ClientError::Malformed)?; + Ok(observation_from(&impact)) +} + +/// Open Terminals are deliberately not observed: they never keep App Server alive (ADR-0071). +pub(crate) fn observation_from(impact: &ClientReplacementImpactResult) -> Observation { + Observation { + active_turns: impact.active_turns, + pending_task_requests: impact.pending_task_requests, + app_shells: impact + .other_clients + .iter() + .filter(|client| !matches!(client.kind, ShellKind::Terminal | ShellKind::Keeper)) + .count(), + } +} + +fn log_verdict(verdict: Verdict, observation: &Observation) { + let (verdict, reason) = match verdict { + Verdict::Hold(reason) => ("hold", reason.class()), + Verdict::Leave(reason) => ("leave", reason.class()), + }; + logging::info( + "keeper_verdict_changed", + json!({ + "verdict": verdict, + "reason": reason, + "active_turns": observation.active_turns, + "pending_task_requests": observation.pending_task_requests, + "app_shells": observation.app_shells, + }), + ); +} + +fn stop(state_root: &StateRoot, runtime_root: &Path) -> i32 { + let started = Instant::now(); + let path = lease::lease_path(runtime_root, state_root.fingerprint().as_str()); + let outcome = match lease::current_holder(&path) { + Ok(None) => "not_running", + Ok(Some(holder)) => { + terminate(holder.pid); + let deadline = Instant::now() + STOP_WAIT; + loop { + match lease::current_holder(&path) { + Ok(None) => break "stopped", + _ if Instant::now() >= deadline => break "still_running", + _ => std::thread::sleep(Duration::from_millis(100)), + } + } + } + Err(_) => "lease_unreadable", + }; + logging::info( + "keeper_stop_completed", + json!({ "outcome": outcome, "duration_ms": elapsed_ms(started) }), + ); + println!("{}", json!({ "outcome": outcome })); + i32::from(outcome != "stopped" && outcome != "not_running") +} + +#[cfg(unix)] +fn terminate(pid: u32) { + use nix::sys::signal::{kill, Signal}; + use nix::unistd::Pid; + if let Ok(pid) = i32::try_from(pid) { + let _ = kill(Pid::from_raw(pid), Signal::SIGTERM); + } +} + +#[cfg(not(unix))] +fn terminate(_pid: u32) {} + +/// Only an explicit stop ends the keeper early. It has no terminal, so a hangup is +/// not a request to stop. +fn termination_flag() -> Arc { + let flag = Arc::new(AtomicBool::new(false)); + #[cfg(unix)] + for signal in [signal_hook::consts::SIGTERM, signal_hook::consts::SIGINT] { + let _ = signal_hook::flag::register(signal, flag.clone()); + } + #[cfg(unix)] + let _ = signal_hook::flag::register( + signal_hook::consts::SIGHUP, + Arc::new(AtomicBool::new(false)), + ); + flag +} + +fn print_report(report: &Report) { + // The reader is `keep start`, which exits after one line; a later write + // failing must not end the keeper. + let line = serde_json::to_string(report).unwrap_or_default(); + let mut stdout = std::io::stdout(); + let _ = writeln!(stdout, "{line}"); + let _ = stdout.flush(); +} + +fn mode_class(mode: Mode) -> &'static str { + match mode { + Mode::Own => "own", + Mode::Replace => "replace", + Mode::Join => "join", + } +} + +fn elapsed_ms(started: Instant) -> u64 { + u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX) +} + +#[cfg(test)] +#[path = "keeper_command_tests.rs"] +mod tests; diff --git a/openaide-rs/app-server/src/keeper_command/lease.rs b/openaide-rs/app-server/src/keeper_command/lease.rs new file mode 100644 index 00000000..fa5e8e17 --- /dev/null +++ b/openaide-rs/app-server/src/keeper_command/lease.rs @@ -0,0 +1,78 @@ +//! One keeper per state root. The keeper holds an exclusive lock on its lease file +//! for as long as it runs and writes its process id and the App Server it keeps +//! into it. The operating system releases the lock when the process ends, so a +//! lease whose lock is free is stale whatever the file says. + +use std::fs::{File, OpenOptions}; +use std::io::{Read, Seek, SeekFrom, Write}; +use std::path::{Path, PathBuf}; + +use fs2::FileExt; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct LeaseHolder { + pub(crate) pid: u32, + pub(crate) server_id: String, +} + +pub(crate) enum LeaseAttempt { + Acquired(Lease), + /// Another keeper runs. Its record is absent while it is still writing it. + Held(Option), +} + +/// The lock, held until dropped. +pub(crate) struct Lease { + _file: File, +} + +pub(crate) fn lease_path(runtime_root: &Path, fingerprint: &str) -> PathBuf { + runtime_root.join(format!("{fingerprint}.keeper")) +} + +pub(crate) fn try_acquire(path: &Path, holder: &LeaseHolder) -> std::io::Result { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let mut file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(path)?; + if file.try_lock_exclusive().is_err() { + return Ok(LeaseAttempt::Held(read_holder(&mut file))); + } + file.set_len(0)?; + file.seek(SeekFrom::Start(0))?; + file.write_all(&serde_json::to_vec(holder).map_err(std::io::Error::other)?)?; + file.sync_data()?; + Ok(LeaseAttempt::Acquired(Lease { _file: file })) +} + +/// The running keeper, or `None` when no keeper holds the lease. +pub(crate) fn current_holder(path: &Path) -> std::io::Result> { + let mut file = match File::open(path) { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error), + }; + if file.try_lock_shared().is_ok() { + let _ = FileExt::unlock(&file); + return Ok(None); + } + Ok(read_holder(&mut file)) +} + +fn read_holder(file: &mut File) -> Option { + let mut text = String::new(); + file.seek(SeekFrom::Start(0)).ok()?; + file.read_to_string(&mut text).ok()?; + serde_json::from_str(&text).ok() +} + +#[cfg(test)] +#[path = "lease_tests.rs"] +mod tests; diff --git a/openaide-rs/app-server/src/keeper_command/lease_tests.rs b/openaide-rs/app-server/src/keeper_command/lease_tests.rs new file mode 100644 index 00000000..a5012939 --- /dev/null +++ b/openaide-rs/app-server/src/keeper_command/lease_tests.rs @@ -0,0 +1,47 @@ +use super::*; + +fn holder(server_id: &str) -> LeaseHolder { + LeaseHolder { + pid: 4242, + server_id: server_id.to_string(), + } +} + +#[test] +fn a_second_keeper_sees_the_first_one_and_its_server() { + let dir = tempfile::tempdir().unwrap(); + let path = lease_path(dir.path(), "root-1"); + + let first = try_acquire(&path, &holder("server-a")).unwrap(); + let second = try_acquire(&path, &holder("server-b")).unwrap(); + + assert!(matches!(first, LeaseAttempt::Acquired(_))); + assert!(matches!( + second, + LeaseAttempt::Held(Some(ref found)) if *found == holder("server-a") + )); + assert_eq!(current_holder(&path).unwrap(), Some(holder("server-a"))); +} + +#[test] +fn a_released_lease_is_free_whatever_the_file_still_says() { + let dir = tempfile::tempdir().unwrap(); + let path = lease_path(dir.path(), "root-1"); + drop(try_acquire(&path, &holder("server-a")).unwrap()); + + assert_eq!(current_holder(&path).unwrap(), None); + let second = try_acquire(&path, &holder("server-b")).unwrap(); + + assert!(matches!(second, LeaseAttempt::Acquired(_))); + assert_eq!(current_holder(&path).unwrap(), Some(holder("server-b"))); +} + +#[test] +fn no_lease_file_means_no_keeper() { + let dir = tempfile::tempdir().unwrap(); + + assert_eq!( + current_holder(&lease_path(dir.path(), "root-1")).unwrap(), + None + ); +} diff --git a/openaide-rs/app-server/src/keeper_command/policy.rs b/openaide-rs/app-server/src/keeper_command/policy.rs new file mode 100644 index 00000000..e8eda2ac --- /dev/null +++ b/openaide-rs/app-server/src/keeper_command/policy.rs @@ -0,0 +1,127 @@ +//! When the keeper holds the App Server on an SSH Computer and when it leaves +//! (ADR-0069). The keeper is an ordinary client; leaving lets the App Server stop +//! by its own rule once no other client remains. + +use std::time::{Duration, Instant}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct KeeperPolicy { + /// How long after the last App Shell left the keeper still holds an idle App Server. + pub(crate) app_shell_grace: Duration, + /// How long Tasks waiting for the user keep holding it with nobody attached. + pub(crate) waiting_limit: Duration, +} + +impl Default for KeeperPolicy { + fn default() -> Self { + Self { + app_shell_grace: Duration::from_secs(30 * 60), + waiting_limit: Duration::from_secs(24 * 60 * 60), + } + } +} + +/// What the keeper read from `client/replacementImpact`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub(crate) struct Observation { + pub(crate) active_turns: u32, + pub(crate) pending_task_requests: u32, + /// Attached clients that are App Shells, not other keepers or `remote` commands. + pub(crate) app_shells: usize, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Verdict { + Hold(HoldReason), + Leave(LeaveReason), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum HoldReason { + RunningWork, + AppShellAttached, + WaitingForUser, + RecentAppShell, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum LeaveReason { + Idle, + WaitedTooLong, +} + +impl HoldReason { + pub(crate) fn class(self) -> &'static str { + match self { + Self::RunningWork => "running_work", + Self::AppShellAttached => "app_shell_attached", + Self::WaitingForUser => "waiting_for_user", + Self::RecentAppShell => "recent_app_shell", + } + } +} + +impl LeaveReason { + pub(crate) fn class(self) -> &'static str { + match self { + Self::Idle => "idle", + Self::WaitedTooLong => "waited_too_long", + } + } +} + +/// The keeper's memory between observations. Waiting time is measured by the keeper +/// from the first observation that saw a waiting Task, so it is never longer than +/// the real wait. +#[derive(Debug, Clone)] +pub(crate) struct KeeperClock { + policy: KeeperPolicy, + last_app_shell: Instant, + waiting_since: Option, +} + +impl KeeperClock { + /// The keeper starts because an App Shell is connecting, so that counts as one + /// just seen. + pub(crate) fn new(policy: KeeperPolicy, started: Instant) -> Self { + Self { + policy, + last_app_shell: started, + waiting_since: None, + } + } + + pub(crate) fn observe(&mut self, observation: &Observation, now: Instant) -> Verdict { + if observation.app_shells > 0 { + self.last_app_shell = now; + } + if observation.pending_task_requests == 0 { + self.waiting_since = None; + } else if self.waiting_since.is_none() { + self.waiting_since = Some(now); + } + + if observation.active_turns > 0 { + return Verdict::Hold(HoldReason::RunningWork); + } + if observation.app_shells > 0 { + return Verdict::Hold(HoldReason::AppShellAttached); + } + if let Some(since) = self.waiting_since { + if now.duration_since(since) < self.policy.waiting_limit { + return Verdict::Hold(HoldReason::WaitingForUser); + } + } + if now.duration_since(self.last_app_shell) < self.policy.app_shell_grace { + return Verdict::Hold(HoldReason::RecentAppShell); + } + Verdict::Leave(match self.waiting_since { + Some(_) => LeaveReason::WaitedTooLong, + None => LeaveReason::Idle, + }) + } +} + +#[cfg(test)] +#[path = "policy_tests.rs"] +mod tests; diff --git a/openaide-rs/app-server/src/keeper_command/policy_tests.rs b/openaide-rs/app-server/src/keeper_command/policy_tests.rs new file mode 100644 index 00000000..beae6d29 --- /dev/null +++ b/openaide-rs/app-server/src/keeper_command/policy_tests.rs @@ -0,0 +1,132 @@ +// timing-file: mocked — every observation passes an explicit instant; nothing waits. +use std::time::{Duration, Instant}; + +use super::*; + +const MINUTE: Duration = Duration::from_secs(60); +const HOUR: Duration = Duration::from_secs(60 * 60); + +fn idle() -> Observation { + Observation::default() +} + +fn shell_attached() -> Observation { + Observation { + app_shells: 1, + ..Observation::default() + } +} + +fn waiting() -> Observation { + Observation { + pending_task_requests: 1, + ..Observation::default() + } +} + +fn running() -> Observation { + Observation { + active_turns: 1, + ..Observation::default() + } +} + +#[test] +fn holds_an_idle_server_for_the_grace_after_the_app_shell_left() { + let start = Instant::now(); + let mut clock = KeeperClock::new(KeeperPolicy::default(), start); + + assert_eq!( + clock.observe(&shell_attached(), start + MINUTE), + Verdict::Hold(HoldReason::AppShellAttached) + ); + assert_eq!( + clock.observe(&idle(), start + 30 * MINUTE), + Verdict::Hold(HoldReason::RecentAppShell) + ); + assert_eq!( + clock.observe(&idle(), start + 31 * MINUTE), + Verdict::Leave(LeaveReason::Idle) + ); +} + +#[test] +fn the_app_shell_that_started_the_keeper_counts_as_just_seen() { + let start = Instant::now(); + let mut clock = KeeperClock::new(KeeperPolicy::default(), start); + + assert_eq!( + clock.observe(&idle(), start + 29 * MINUTE), + Verdict::Hold(HoldReason::RecentAppShell) + ); + assert_eq!( + clock.observe(&idle(), start + 30 * MINUTE), + Verdict::Leave(LeaveReason::Idle) + ); +} + +#[test] +fn running_work_holds_without_limit() { + let start = Instant::now(); + let mut clock = KeeperClock::new(KeeperPolicy::default(), start); + + assert_eq!( + clock.observe(&running(), start + 72 * HOUR), + Verdict::Hold(HoldReason::RunningWork) + ); +} + +#[test] +fn a_waiting_task_holds_for_a_day_then_the_keeper_leaves() { + let start = Instant::now(); + let mut clock = KeeperClock::new(KeeperPolicy::default(), start); + + assert_eq!( + clock.observe(&waiting(), start + HOUR), + Verdict::Hold(HoldReason::WaitingForUser) + ); + assert_eq!( + clock.observe(&waiting(), start + 24 * HOUR + 59 * MINUTE), + Verdict::Hold(HoldReason::WaitingForUser) + ); + assert_eq!( + clock.observe(&waiting(), start + 25 * HOUR), + Verdict::Leave(LeaveReason::WaitedTooLong) + ); +} + +#[test] +fn an_answered_request_restarts_the_waiting_limit() { + let start = Instant::now(); + let mut clock = KeeperClock::new(KeeperPolicy::default(), start); + + clock.observe(&waiting(), start + HOUR); + clock.observe(&running(), start + 20 * HOUR); + + assert_eq!( + clock.observe(&waiting(), start + 30 * HOUR), + Verdict::Hold(HoldReason::WaitingForUser) + ); + assert_eq!( + clock.observe(&waiting(), start + 54 * HOUR), + Verdict::Leave(LeaveReason::WaitedTooLong) + ); +} + +#[test] +fn an_attached_app_shell_holds_past_the_waiting_limit() { + let start = Instant::now(); + let mut clock = KeeperClock::new(KeeperPolicy::default(), start); + let attached_and_waiting = Observation { + pending_task_requests: 1, + app_shells: 1, + ..Observation::default() + }; + + clock.observe(&attached_and_waiting, start); + + assert_eq!( + clock.observe(&attached_and_waiting, start + 48 * HOUR), + Verdict::Hold(HoldReason::AppShellAttached) + ); +} diff --git a/openaide-rs/app-server/src/keeper_command_tests.rs b/openaide-rs/app-server/src/keeper_command_tests.rs new file mode 100644 index 00000000..674947e7 --- /dev/null +++ b/openaide-rs/app-server/src/keeper_command_tests.rs @@ -0,0 +1,64 @@ +use openaide_app_server_protocol::client::{ + AttachedClientSummary, ClientReplacementImpactResult, ShellKind, +}; + +use super::*; + +fn words(text: &str) -> Vec { + text.split_whitespace().map(str::to_string).collect() +} + +fn client(kind: ShellKind) -> AttachedClientSummary { + AttachedClientSummary { + kind, + name: None, + app_version: None, + protocol_version: None, + reconnecting: false, + } +} + +#[test] +fn reads_start_modes_and_refuses_anything_else() { + assert_eq!(parse(&words("start")), Ok(Action::Start(Mode::Own))); + assert_eq!( + parse(&words("start --replace")), + Ok(Action::Start(Mode::Replace)) + ); + assert_eq!(parse(&words("run --join")), Ok(Action::Run(Mode::Join))); + assert_eq!(parse(&words("stop")), Ok(Action::Stop)); + assert!(parse(&words("start --replace --join")).is_err()); + assert!(parse(&words("stop now")).is_err()); + assert!(parse(&words("restart")).is_err()); +} + +#[test] +fn only_app_shells_count_as_attached() { + let impact = ClientReplacementImpactResult { + other_clients: vec![ + client(ShellKind::Desktop), + client(ShellKind::Mobile), + client(ShellKind::Terminal), + client(ShellKind::Keeper), + ], + active_turns: 1, + pending_task_requests: 2, + open_terminals: 3, + }; + + assert_eq!( + observation_from(&impact), + Observation { + active_turns: 1, + pending_task_requests: 2, + app_shells: 2, + } + ); +} + +#[test] +fn a_failure_report_names_only_its_class() { + let line = serde_json::to_string(&Report::failed("not_running")).unwrap(); + + assert_eq!(line, r#"{"outcome":"failed","error":"not_running"}"#); +} diff --git a/openaide-rs/app-server/src/lib.rs b/openaide-rs/app-server/src/lib.rs index 323d7702..877f34dc 100644 --- a/openaide-rs/app-server/src/lib.rs +++ b/openaide-rs/app-server/src/lib.rs @@ -14,6 +14,7 @@ pub(crate) mod native_sessions; pub mod projects; pub mod protocol; pub mod protocol_edge; +pub(crate) mod pty_child; pub mod remote_devices; pub mod server_requests; pub mod settings; @@ -25,6 +26,7 @@ pub mod storage_runtime; pub mod task_events; pub(crate) mod task_recovery; pub mod tasks; +pub mod terminals; #[cfg(test)] pub(crate) mod test_sync; pub mod time; diff --git a/openaide-rs/app-server/src/main.rs b/openaide-rs/app-server/src/main.rs index 65473b99..5f3523a1 100644 --- a/openaide-rs/app-server/src/main.rs +++ b/openaide-rs/app-server/src/main.rs @@ -13,6 +13,7 @@ use openaide_app_server::transport::shell_control::ShellControlDispatcher; use openaide_app_server::Runtime; mod app_server_handoff; +mod keeper_command; mod protocol_edge_process; mod remote_command; @@ -43,6 +44,9 @@ fn main() { if arguments.first().map(String::as_str) == Some("remote") { std::process::exit(run_remote_command(&arguments[1..], &storage_root)); } + if arguments.first().map(String::as_str) == Some("keep") { + std::process::exit(run_keeper_command(&arguments[1..], &storage_root)); + } match protocol_mode().as_deref() { Ok(APP_SERVER_PROTOCOL_MODE) => { @@ -140,11 +144,26 @@ fn run_shell_control_stdio(storage_root: PathBuf) { } fn run_remote_command(arguments: &[String], storage_root: &std::path::Path) -> i32 { + with_roots(storage_root, |state_root, runtime_root| { + remote_command::run(arguments, state_root, runtime_root) + }) +} + +fn run_keeper_command(arguments: &[String], storage_root: &std::path::Path) -> i32 { + with_roots(storage_root, |state_root, runtime_root| { + keeper_command::run(arguments, state_root, runtime_root) + }) +} + +fn with_roots( + storage_root: &std::path::Path, + command: impl FnOnce(&StateRoot, &std::path::Path) -> i32, +) -> i32 { let runtime_root = env::var_os(RUNTIME_ROOT_ENV) .map(PathBuf::from) .unwrap_or_else(default_runtime_root); match StateRoot::resolve(storage_root) { - Ok(state_root) => remote_command::run(arguments, &state_root, &runtime_root), + Ok(state_root) => command(&state_root, &runtime_root), Err(error) => { openaide_app_server::logging::error( "protocol_edge_state_root_failed", diff --git a/openaide-rs/app-server/src/protocol_edge.rs b/openaide-rs/app-server/src/protocol_edge.rs index 331b8581..d99ae77d 100644 --- a/openaide-rs/app-server/src/protocol_edge.rs +++ b/openaide-rs/app-server/src/protocol_edge.rs @@ -29,6 +29,7 @@ mod task_attention_handlers; mod task_config_handlers; mod task_handlers; mod task_update_publisher; +mod terminal_handlers; mod worktree_handlers; pub use delivery_signal::DeliverySignal; @@ -116,6 +117,7 @@ pub struct RpcGateway { update_shutdown: Option, remote_devices: Arc, remote_device_service: Option, + pub(crate) terminals: crate::terminals::Terminals, } pub(crate) trait AppServerShutdownWorkflow: Send + Sync { @@ -199,6 +201,7 @@ impl RpcGateway { worktrees: Arc, shutdown: Arc, ) -> Self { + let terminals = crate::terminals::Terminals::default(); Self { client_hub, lifecycle, @@ -208,7 +211,7 @@ impl RpcGateway { ready_catalog_agents: Default::default(), shell_file_reveals, file_viewer: crate::file_viewer::FileViewerRegistry::new(), - snapshots, + snapshots: snapshots.with_terminals(terminals.clone()), task_snapshots, project_roots, probe_facts, @@ -242,6 +245,7 @@ impl RpcGateway { update_shutdown: None, remote_devices: Arc::new(crate::remote_devices::NoRemoteDevices), remote_device_service: None, + terminals, } } diff --git a/openaide-rs/app-server/src/protocol_edge/client_handlers.rs b/openaide-rs/app-server/src/protocol_edge/client_handlers.rs index c5f3ba16..ff4d2be9 100644 --- a/openaide-rs/app-server/src/protocol_edge/client_handlers.rs +++ b/openaide-rs/app-server/src/protocol_edge/client_handlers.rs @@ -1,6 +1,7 @@ use openaide_app_server_protocol::client::{ ClientCapabilitiesChangedParams, ClientCapabilitiesChangedResult, ClientDetachParams, - ClientDetachResult, ClientHeartbeatParams, ClientHeartbeatResult, UpdateShutdownAbortParams, + ClientDetachResult, ClientHeartbeatParams, ClientHeartbeatResult, + ClientReplacementImpactParams, ClientReplacementImpactResult, UpdateShutdownAbortParams, UpdateShutdownAbortResult, UpdateShutdownBlockedReason, UpdateShutdownCommitParams, UpdateShutdownCommitResult, UpdateShutdownPrepareParams, UpdateShutdownPrepareResult, }; @@ -123,6 +124,50 @@ impl RpcGateway { responses::result(connection_id, id, meta, ClientDetachResult {}) } + /// Reports who and what a replacement by another version would interrupt, without + /// starting one. The caller is excluded: it asked and already knows. + pub(super) fn handle_client_replacement_impact( + &mut self, + connection_id: ConnectionId, + id: String, + params: Value, + meta: RequestMeta, + ) -> GatewayOutcome { + if let Err(error) = serde_json::from_value::(params) { + return self.error(connection_id, id, meta, responses::invalid_params(error)); + } + let Some(context) = self.client_hub.context_for_connection(&connection_id) else { + return self.error( + connection_id, + id, + meta, + responses::not_initialized( + openaide_app_server_protocol::methods::CLIENT_REPLACEMENT_IMPACT.to_string(), + ), + ); + }; + let blockers = match self.shutdown.shutdown_blockers() { + Ok(blockers) => blockers, + Err(_error) => { + return self.error( + connection_id, + id, + meta, + responses::replacement_impact_failed(), + ) + } + }; + let result = ClientReplacementImpactResult { + other_clients: self + .client_hub + .other_client_summaries(&context.client_instance_id), + active_turns: saturating_u32(blockers.active_turns), + pending_task_requests: saturating_u32(blockers.pending_task_requests), + open_terminals: saturating_u32(self.terminals.open_count()), + }; + responses::result(connection_id, id, meta, result) + } + pub(super) fn handle_update_shutdown_prepare( &mut self, connection_id: ConnectionId, @@ -204,8 +249,12 @@ impl RpcGateway { ) } }; + // An open Terminal is active work the update would end, so it needs the same + // confirmation; the client reads the count from the Terminal list. if !params.stop_active_work - && (blockers.active_turns > 0 || blockers.pending_task_requests > 0) + && (blockers.active_turns > 0 + || blockers.pending_task_requests > 0 + || self.terminals.open_count() > 0) { return responses::result( connection_id, @@ -310,6 +359,10 @@ impl RpcGateway { } } +fn saturating_u32(count: usize) -> u32 { + count.try_into().unwrap_or(u32::MAX) +} + fn valid_update_attempt_id(attempt_id: &str) -> bool { !attempt_id.is_empty() && attempt_id.len() <= 128 } diff --git a/openaide-rs/app-server/src/protocol_edge/local_http.rs b/openaide-rs/app-server/src/protocol_edge/local_http.rs index 6943f49e..5ef0005e 100644 --- a/openaide-rs/app-server/src/protocol_edge/local_http.rs +++ b/openaide-rs/app-server/src/protocol_edge/local_http.rs @@ -152,6 +152,11 @@ impl LocalHttpAppHandler { self.protocol.drain_push_messages(lease) } + /// The Terminal socket redeems attach tokens here without taking the protocol lock per frame. + pub(crate) fn terminals(&self) -> crate::terminals::Terminals { + self.probe.gateway.terminals() + } + pub(crate) fn authorize_upload( &self, authorization: Option<&str>, diff --git a/openaide-rs/app-server/src/protocol_edge/local_http/listener.rs b/openaide-rs/app-server/src/protocol_edge/local_http/listener.rs index 5328acbd..f5ab49c5 100644 --- a/openaide-rs/app-server/src/protocol_edge/local_http/listener.rs +++ b/openaide-rs/app-server/src/protocol_edge/local_http/listener.rs @@ -13,6 +13,7 @@ const POLL_FALLBACK_WAKE: Duration = Duration::from_millis(250); mod http; mod session_socket; +mod terminal_socket; mod uploads; mod viewer_downloads; mod websocket; @@ -22,6 +23,7 @@ use http::{ write_event_stream_heartbeat, write_file_download, write_http_response, }; use session_socket::handle_session_socket; +use terminal_socket::handle_terminal_socket; use uploads::handle_file_upload; pub struct LocalHttpProbeListener { @@ -138,7 +140,18 @@ pub fn handle_app_stream( |_stream, request| Ok(handle_session_poll(handler, request)), |stream, request| handle_file_upload(stream, handler, request), |stream, request| handle_file_download(stream, handler, request), - |stream, request| handle_session_socket(stream, handler, request), + |stream, request| { + let terminal = request + .target + .split('?') + .next() + .is_some_and(|path| path.ends_with(crate::terminals::TERMINAL_SOCKET_PATH)); + if terminal { + handle_terminal_socket(stream, handler, request) + } else { + handle_session_socket(stream, handler, request) + } + }, ) } diff --git a/openaide-rs/app-server/src/protocol_edge/local_http/listener/session_socket.rs b/openaide-rs/app-server/src/protocol_edge/local_http/listener/session_socket.rs index 3c3d870e..69386c4c 100644 --- a/openaide-rs/app-server/src/protocol_edge/local_http/listener/session_socket.rs +++ b/openaide-rs/app-server/src/protocol_edge/local_http/listener/session_socket.rs @@ -306,6 +306,10 @@ fn read( }; let text = match incoming { Incoming::Text(text) => text, + Incoming::Binary(_) => { + socket.end("unsupported_frame", Some(CLOSE_PROTOCOL_VIOLATION)); + return; + } Incoming::Ping(payload) => { socket.pong(&payload); continue; diff --git a/openaide-rs/app-server/src/protocol_edge/local_http/listener/terminal_socket.rs b/openaide-rs/app-server/src/protocol_edge/local_http/listener/terminal_socket.rs new file mode 100644 index 00000000..907724f6 --- /dev/null +++ b/openaide-rs/app-server/src/protocol_edge/local_http/listener/terminal_socket.rs @@ -0,0 +1,317 @@ +//! WebSocket carrying one Terminal's bytes (ADR-0071). Lifecycle stays on the App Server +//! Protocol; this socket only streams, so keystrokes never queue behind other traffic. +//! +//! Wire messages: +//! client -> server text hello { token, cols, rows }, then binary input, text resize { cols, rows }, +//! and text pong answering each ping +//! server -> client text ready { terminalId }, then binary output: the replay first, then live, +//! with a text ping every `SERVER_PING_INTERVAL` +//! The token comes from `terminals/open` or `terminals/attach` and is redeemed once. Close codes +//! tell the client what to do next (see the constants). + +use std::io::{BufReader, ErrorKind}; +use std::net::{Shutdown, TcpStream}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::mpsc::RecvTimeoutError; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use serde::Deserialize; +use serde_json::json; + +use crate::logging; +use crate::protocol_edge::local_http::{LocalHttpAppHandler, LocalHttpResponse}; +use crate::terminals::{valid_size, Shell}; + +use super::http::write_http_response; +use super::websocket::{ + write_binary, write_close, write_handshake, write_pong, write_text, Incoming, MessageReader, +}; +use super::{LocalHttpProbeListenerError, LocalHttpRequest}; + +const HELLO_TIMEOUT: Duration = Duration::from_secs(10); +/// Three missed pongs. The client applies the same budget to the server's pings. +const READ_IDLE_TIMEOUT: Duration = Duration::from_secs(45); +/// Pings are text, not WebSocket control frames, because a browser answers those itself and +/// never shows them to the page, which then could not notice a server that went silent. They +/// run on a timer, not on idle output, so a viewer that only watches a busy shell still pongs. +#[cfg(not(test))] +const SERVER_PING_INTERVAL: Duration = Duration::from_secs(15); +#[cfg(test)] +const SERVER_PING_INTERVAL: Duration = Duration::from_millis(200); +const PING: &str = r#"{"type":"ping"}"#; +/// A viewer that cannot take output this long is gone or too slow; it reattaches. +const WRITE_TIMEOUT: Duration = Duration::from_secs(10); +/// Larger pastes arrive as several messages. +const MAX_MESSAGE_BYTES: usize = 1024 * 1024; +/// Keeps one replay frame from monopolizing a relayed path. +const REPLAY_FRAME_BYTES: usize = 64 * 1024; + +/// The shell ended or the Terminal closed; the published status says which. +const CLOSE_TERMINAL_ENDED: u16 = 1000; +const CLOSE_PROTOCOL_VIOLATION: u16 = 4400; +/// The token is unknown, used, or expired; ask `terminals/attach` for another. +const CLOSE_UNAUTHORIZED: u16 = 4401; +/// This viewer fell behind the output and was dropped; reattach to resume from the replay. +const CLOSE_VIEWER_BEHIND: u16 = 4408; + +#[derive(Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +enum ClientMessage { + Hello { token: String, cols: u16, rows: u16 }, + Resize { cols: u16, rows: u16 }, + Pong, +} + +/// Serializes writes from the reader and the output pump, and records the first end reason. +struct Socket { + writer: Mutex, + closed: AtomicBool, + reason: Mutex<&'static str>, + bytes_in: AtomicU64, + bytes_out: AtomicU64, +} + +impl Socket { + fn write(&self, write: impl FnOnce(&mut TcpStream) -> std::io::Result<()>) -> bool { + if self.closed.load(Ordering::Acquire) { + return false; + } + let mut writer = self.writer.lock().expect("terminal socket writer poisoned"); + if write(&mut writer).is_err() { + drop(writer); + self.end("write_failed", None); + return false; + } + true + } + + /// Ends the socket once. Shutting it down also releases the blocked reader. + fn end(&self, reason: &'static str, code: Option) { + if self.closed.swap(true, Ordering::AcqRel) { + return; + } + *self.reason.lock().expect("terminal socket reason poisoned") = reason; + let mut writer = self.writer.lock().expect("terminal socket writer poisoned"); + if let Some(code) = code { + let _ = write_close(&mut *writer, code, reason); + } + let _ = writer.shutdown(Shutdown::Both); + } + + fn reason(&self) -> &'static str { + *self.reason.lock().expect("terminal socket reason poisoned") + } +} + +pub(super) fn handle_terminal_socket( + stream: &mut TcpStream, + handler: &LocalHttpAppHandler, + request: LocalHttpRequest, +) -> Result<(), LocalHttpProbeListenerError> { + let Some(key) = request + .websocket_key + .as_deref() + .filter(|_| request.websocket_version.as_deref() == Some("13")) + else { + logging::warn( + "terminal_socket_rejected", + json!({ "reason_code": "invalid_upgrade" }), + ); + return write_http_response( + stream, + &LocalHttpResponse { + status: 400, + body: String::new(), + }, + ); + }; + let started_at = Instant::now(); + stream.set_nodelay(true)?; + stream.set_read_timeout(Some(HELLO_TIMEOUT))?; + stream.set_write_timeout(Some(WRITE_TIMEOUT))?; + write_handshake(stream, key)?; + let socket = Socket { + writer: Mutex::new(stream.try_clone()?), + closed: AtomicBool::new(false), + reason: Mutex::new("unknown"), + bytes_in: AtomicU64::new(0), + bytes_out: AtomicU64::new(0), + }; + let mut reader = MessageReader::new(BufReader::new(stream.try_clone()?), MAX_MESSAGE_BYTES); + + let shell = match read_hello(&mut reader, handler) { + Ok(shell) => shell, + Err((reason, code)) => { + logging::warn( + "terminal_socket_rejected", + json!({ "reason_code": reason, "duration_ms": started_at.elapsed().as_millis() }), + ); + socket.end(reason, code); + return Ok(()); + } + }; + let (viewer_id, attach) = shell.attach(); + logging::info( + "terminal_socket_opened", + json!({ + "terminal_id": shell.terminal_id, + "viewer_id": viewer_id, + "replay_bytes": attach.replay.len(), + "viewers": shell.viewer_count(), + }), + ); + socket.write(|writer| { + write_text( + writer, + &json!({ "type": "ready", "terminalId": shell.terminal_id }).to_string(), + ) + }); + for chunk in attach.replay.chunks(REPLAY_FRAME_BYTES) { + if !socket.write(|writer| write_binary(writer, chunk)) { + break; + } + socket + .bytes_out + .fetch_add(chunk.len() as u64, Ordering::Relaxed); + } + stream.set_read_timeout(Some(READ_IDLE_TIMEOUT))?; + + std::thread::scope(|scope| { + scope.spawn(|| pump(&shell, &socket, attach.output)); + read(&mut reader, &shell, &socket); + // Dropping the viewer's channel wakes the pump now instead of at its next ping. + shell.detach(viewer_id); + }); + logging::info( + "terminal_socket_closed", + json!({ + "terminal_id": shell.terminal_id, + "viewer_id": viewer_id, + "reason_code": socket.reason(), + "bytes_in": socket.bytes_in.load(Ordering::Relaxed), + "bytes_out": socket.bytes_out.load(Ordering::Relaxed), + "duration_ms": started_at.elapsed().as_millis(), + }), + ); + Ok(()) +} + +fn read_hello( + reader: &mut MessageReader>, + handler: &LocalHttpAppHandler, +) -> Result, (&'static str, Option)> { + let hello = match reader.read() { + Ok(Incoming::Text(text)) => serde_json::from_str::(&text).ok(), + Ok(_) => None, + Err(error) => return Err((read_failure_reason(&error), None)), + }; + let Some(ClientMessage::Hello { token, cols, rows }) = hello else { + return Err(("invalid_hello", Some(CLOSE_PROTOCOL_VIOLATION))); + }; + let shell = handler + .terminals() + .claim(&token) + .ok_or(("invalid_token", Some(CLOSE_UNAUTHORIZED)))?; + // The most recently attached client sets the size, as it does with each later resize. + if valid_size(cols, rows) { + shell.resize(cols, rows); + } + Ok(shell) +} + +/// Forwards live output and pings on a fixed schedule, busy shell or quiet. +fn pump(shell: &Shell, socket: &Socket, output: std::sync::mpsc::Receiver>) { + let mut next_ping = Instant::now() + SERVER_PING_INTERVAL; + loop { + if Instant::now() >= next_ping { + if !socket.write(|writer| write_text(writer, PING)) { + return; + } + next_ping = Instant::now() + SERVER_PING_INTERVAL; + } + match output.recv_timeout(next_ping.saturating_duration_since(Instant::now())) { + Ok(chunk) => { + if !socket.write(|writer| write_binary(writer, &chunk)) { + return; + } + socket + .bytes_out + .fetch_add(chunk.len() as u64, Ordering::Relaxed); + } + Err(RecvTimeoutError::Timeout) => {} + Err(RecvTimeoutError::Disconnected) => { + if shell.output_ended() { + socket.end("terminal_ended", Some(CLOSE_TERMINAL_ENDED)); + } else { + socket.end("viewer_behind", Some(CLOSE_VIEWER_BEHIND)); + } + return; + } + } + } +} + +fn read(reader: &mut MessageReader>, shell: &Shell, socket: &Socket) { + loop { + let incoming = match reader.read() { + Ok(incoming) => incoming, + Err(error) => { + let code = + (error.kind() == ErrorKind::InvalidData).then_some(CLOSE_PROTOCOL_VIOLATION); + socket.end(read_failure_reason(&error), code); + return; + } + }; + match incoming { + Incoming::Binary(bytes) => { + socket + .bytes_in + .fetch_add(bytes.len() as u64, Ordering::Relaxed); + if !bytes.is_empty() && !shell.write_input(bytes) { + socket.end("terminal_ended", Some(CLOSE_TERMINAL_ENDED)); + return; + } + } + Incoming::Text(text) => match serde_json::from_str::(&text) { + Ok(ClientMessage::Pong) => {} + Ok(ClientMessage::Resize { cols, rows }) if valid_size(cols, rows) => { + if !shell.resize(cols, rows) { + logging::warn( + "terminal_resize_failed", + json!({ "terminal_id": shell.terminal_id }), + ); + } + } + _ => { + socket.end("invalid_message", Some(CLOSE_PROTOCOL_VIOLATION)); + return; + } + }, + Incoming::Ping(payload) => { + socket.write(|writer| write_pong(writer, &payload)); + } + Incoming::Pong => {} + Incoming::Close => { + socket.end("client_closed", Some(1000)); + return; + } + } + if socket.closed.load(Ordering::Acquire) { + return; + } + } +} + +fn read_failure_reason(error: &std::io::Error) -> &'static str { + match error.kind() { + ErrorKind::WouldBlock | ErrorKind::TimedOut => "read_timeout", + ErrorKind::UnexpectedEof => "peer_closed", + ErrorKind::InvalidData => "protocol_violation", + _ => "read_failed", + } +} + +#[cfg(all(test, unix))] +#[path = "terminal_socket_tests.rs"] +mod tests; diff --git a/openaide-rs/app-server/src/protocol_edge/local_http/listener/terminal_socket_tests.rs b/openaide-rs/app-server/src/protocol_edge/local_http/listener/terminal_socket_tests.rs new file mode 100644 index 00000000..450e0446 --- /dev/null +++ b/openaide-rs/app-server/src/protocol_edge/local_http/listener/terminal_socket_tests.rs @@ -0,0 +1,273 @@ +use std::io::{Read, Write}; +use std::net::{SocketAddr, TcpListener, TcpStream}; +use std::path::Path; +use std::sync::mpsc; +use std::time::Instant; + +use openaide_app_server_protocol::ids::ProjectId; +use serde_json::{json, Value}; + +use crate::protocol_edge::local_http::listener::handle_app_stream; +use crate::protocol_edge::local_http::LocalHttpAppHandler; +use crate::protocol_edge::stdio::ProtocolEdgeStdioDispatcher; +use crate::protocol_edge::SharedRpcGateway; +use crate::storage_runtime::StateRoot; +use crate::test_sync::WATCHDOG; + +struct Server { + address: SocketAddr, + gateway: SharedRpcGateway, + _state_dir: tempfile::TempDir, + _shutdown: mpsc::Receiver<()>, +} + +impl Server { + fn start() -> Self { + let state_dir = tempfile::TempDir::new().expect("state dir"); + let state_root = StateRoot::resolve(state_dir.path()).expect("state root"); + let gateway = ProtocolEdgeStdioDispatcher::new_for_test(state_root).shared_gateway(); + let (shutdown_sender, shutdown) = mpsc::channel(); + let handler = LocalHttpAppHandler::new( + gateway.clone(), + "token", + "server-1", + "replacement-token", + shutdown_sender, + ); + let listener = TcpListener::bind("127.0.0.1:0").expect("bind loopback"); + let address = listener.local_addr().expect("listener address"); + std::thread::spawn(move || { + for stream in listener.incoming() { + let Ok(mut stream) = stream else { return }; + let handler = handler.clone(); + std::thread::spawn(move || { + let _ = handle_app_stream(&mut stream, &handler); + }); + } + }); + Self { + address, + gateway, + _state_dir: state_dir, + _shutdown: shutdown, + } + } + + fn open_terminal(&self) -> (String, String) { + let opened = self + .gateway + .terminals() + .open( + ProjectId::from("project".to_string()), + Path::new("/"), + 80, + 24, + "project", + ) + .expect("shell starts"); + (opened.terminal.terminal_id, opened.attach.token) + } + + fn connect(&self) -> Client { + let mut stream = TcpStream::connect(self.address).expect("connect"); + stream + .set_read_timeout(Some(crate::test_sync::WATCHDOG)) + .expect("read timeout"); + write!( + stream, + "GET /terminal HTTP/1.1\r\nHost: localhost\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\n\r\n" + ) + .expect("write upgrade"); + let mut response = Vec::new(); + while !response.ends_with(b"\r\n\r\n") { + let mut byte = [0_u8; 1]; + stream.read_exact(&mut byte).expect("read upgrade response"); + response.push(byte[0]); + } + assert!(response.starts_with(b"HTTP/1.1 101")); + Client { stream } + } + + fn attach(&self, token: &str) -> Client { + let mut client = self.connect(); + client.send_text(&json!({ "type": "hello", "token": token, "cols": 100, "rows": 30 })); + match client.receive() { + Received::Text(ready) => assert_eq!(ready["type"], "ready", "{ready}"), + other => panic!("expected ready, got {other:?}"), + } + client + } +} + +#[derive(Debug, PartialEq)] +enum Received { + Text(Value), + Binary(Vec), + Ping, + Closed(u16), +} + +struct Client { + stream: TcpStream, +} + +impl Client { + fn send_text(&mut self, message: &Value) { + self.send_frame(0x81, message.to_string().as_bytes()); + } + + fn send_frame(&mut self, first_byte: u8, payload: &[u8]) { + let mask = [7_u8, 11, 13, 17]; + let mut frame = vec![first_byte, 0x80 | payload.len() as u8]; + assert!(payload.len() <= 125); + frame.extend_from_slice(&mask); + frame.extend( + payload + .iter() + .enumerate() + .map(|(i, byte)| byte ^ mask[i % 4]), + ); + self.stream.write_all(&frame).expect("write frame"); + } + + fn receive(&mut self) -> Received { + let mut header = [0_u8; 2]; + self.stream.read_exact(&mut header).expect("frame header"); + let length = match header[1] & 0x7F { + 126 => { + let mut bytes = [0_u8; 2]; + self.stream.read_exact(&mut bytes).unwrap(); + u16::from_be_bytes(bytes) as usize + } + 127 => { + let mut bytes = [0_u8; 8]; + self.stream.read_exact(&mut bytes).unwrap(); + u64::from_be_bytes(bytes) as usize + } + short => short as usize, + }; + let mut payload = vec![0_u8; length]; + self.stream.read_exact(&mut payload).expect("frame payload"); + match header[0] & 0x0F { + 0x1 => Received::Text(serde_json::from_slice(&payload).unwrap()), + 0x2 => Received::Binary(payload), + 0x9 => Received::Ping, + 0x8 => Received::Closed(u16::from_be_bytes([payload[0], payload[1]])), + opcode => panic!("unexpected opcode {opcode}"), + } + } + + /// Collects output until it contains `needle`. + fn output_until(&mut self, needle: &str) -> String { + let deadline = Instant::now() + WATCHDOG; + let mut seen = Vec::new(); + while !String::from_utf8_lossy(&seen).contains(needle) { + assert!(Instant::now() < deadline, "no {needle:?} in output"); + match self.receive() { + Received::Binary(bytes) => seen.extend(bytes), + Received::Ping => {} + Received::Text(message) if message["type"] == "ping" => {} + other => panic!("unexpected {other:?}"), + } + } + String::from_utf8_lossy(&seen).into_owned() + } +} + +#[test] +fn streams_input_and_output_and_replays_to_a_reattaching_client() { + let server = Server::start(); + let (terminal_id, token) = server.open_terminal(); + let mut first = server.attach(&token); + + first.send_frame(0x82, b"echo socket-$((6*7))\r"); + first.output_until("socket-42"); + drop(first); + + let renewed = server.gateway.terminals().attach(&terminal_id).unwrap(); + let mut second = server.attach(&renewed.token); + second.output_until("socket-42"); + server.gateway.terminals().close_all(); +} + +#[test] +fn a_used_token_is_refused() { + let server = Server::start(); + let (_, token) = server.open_terminal(); + let _first = server.attach(&token); + + let mut second = server.connect(); + second.send_text(&json!({ "type": "hello", "token": token, "cols": 80, "rows": 24 })); + assert_eq!(second.receive(), Received::Closed(4401)); + server.gateway.terminals().close_all(); +} + +#[test] +fn closing_the_terminal_ends_its_socket() { + let server = Server::start(); + let (terminal_id, token) = server.open_terminal(); + let mut client = server.attach(&token); + + server + .gateway + .terminals() + .close(&terminal_id, true) + .unwrap(); + let deadline = Instant::now() + WATCHDOG; + loop { + assert!(Instant::now() < deadline); + match client.receive() { + Received::Closed(code) => { + assert_eq!(code, 1000); + break; + } + Received::Binary(_) | Received::Ping | Received::Text(_) => {} + } + } +} + +#[test] +fn a_malformed_message_is_a_protocol_violation() { + let server = Server::start(); + let (_, token) = server.open_terminal(); + let mut client = server.attach(&token); + + client.send_text(&json!({ "type": "frame" })); + loop { + match client.receive() { + Received::Closed(code) => { + assert_eq!(code, 4400); + break; + } + Received::Binary(_) | Received::Ping | Received::Text(_) => {} + } + } + server.gateway.terminals().close_all(); +} + +#[test] +fn a_viewer_watching_a_busy_shell_is_still_pinged_and_its_pongs_keep_it() { + let server = Server::start(); + let (_, token) = server.open_terminal(); + let mut client = server.attach(&token); + + // Output never pauses for a whole ping interval, which used to suppress the ping. + client.send_frame(0x82, b"while :; do echo tick; sleep 0.05; done\r"); + let deadline = Instant::now() + WATCHDOG; + let mut pings = 0; + while pings < 3 { + assert!( + Instant::now() < deadline, + "no ping during continuous output" + ); + match client.receive() { + Received::Text(message) if message["type"] == "ping" => { + pings += 1; + client.send_text(&json!({ "type": "pong" })); + } + Received::Binary(_) | Received::Ping => {} + other => panic!("unexpected {other:?}"), + } + } + server.gateway.terminals().close_all(); +} diff --git a/openaide-rs/app-server/src/protocol_edge/local_http/listener/websocket.rs b/openaide-rs/app-server/src/protocol_edge/local_http/listener/websocket.rs index f99015c5..7c6f14fb 100644 --- a/openaide-rs/app-server/src/protocol_edge/local_http/listener/websocket.rs +++ b/openaide-rs/app-server/src/protocol_edge/local_http/listener/websocket.rs @@ -1,5 +1,6 @@ -//! Server half of RFC 6455, limited to what the session socket uses: text -//! messages, ping/pong, and close. No extensions are negotiated. +//! Server half of RFC 6455, limited to what the session and Terminal sockets +//! use: text and binary messages, ping/pong, and close. No extensions are +//! negotiated. use std::io::{self, ErrorKind, Read, Write}; @@ -7,6 +8,7 @@ const HANDSHAKE_GUID: &str = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; const OPCODE_CONTINUATION: u8 = 0x0; const OPCODE_TEXT: u8 = 0x1; +const OPCODE_BINARY: u8 = 0x2; const OPCODE_CLOSE: u8 = 0x8; const OPCODE_PING: u8 = 0x9; const OPCODE_PONG: u8 = 0xA; @@ -15,6 +17,7 @@ const MAX_CONTROL_PAYLOAD_BYTES: usize = 125; #[derive(Debug, PartialEq, Eq)] pub(super) enum Incoming { Text(String), + Binary(Vec), Ping(Vec), Pong, Close, @@ -39,6 +42,10 @@ pub(super) fn write_text(writer: &mut impl Write, text: &str) -> io::Result<()> write_frame(writer, OPCODE_TEXT, text.as_bytes()) } +pub(super) fn write_binary(writer: &mut impl Write, bytes: &[u8]) -> io::Result<()> { + write_frame(writer, OPCODE_BINARY, bytes) +} + pub(super) fn write_pong(writer: &mut impl Write, payload: &[u8]) -> io::Result<()> { write_frame(writer, OPCODE_PONG, payload) } @@ -76,8 +83,10 @@ fn write_frame(writer: &mut impl Write, opcode: u8, payload: &[u8]) -> io::Resul pub(super) struct MessageReader { reader: R, max_message_bytes: usize, - /// Fragments received so far; control frames may arrive between them. + /// Fragments received so far and whether they form a binary message; + /// control frames may arrive between them. partial: Option>, + binary: bool, } impl MessageReader { @@ -86,6 +95,7 @@ impl MessageReader { reader, max_message_bytes, partial: None, + binary: false, } } @@ -125,7 +135,10 @@ impl MessageReader { OPCODE_PING => return Ok(Incoming::Ping(payload)), OPCODE_PONG => return Ok(Incoming::Pong), OPCODE_CLOSE => return Ok(Incoming::Close), - OPCODE_TEXT if self.partial.is_none() => self.partial = Some(payload), + OPCODE_TEXT | OPCODE_BINARY if self.partial.is_none() => { + self.binary = opcode == OPCODE_BINARY; + self.partial = Some(payload); + } OPCODE_CONTINUATION => match self.partial.as_mut() { Some(message) => message.extend_from_slice(&payload), None => return Err(violation("continuation without a message")), @@ -137,6 +150,9 @@ impl MessageReader { .partial .take() .expect("a data frame started the message"); + if self.binary { + return Ok(Incoming::Binary(bytes)); + } return String::from_utf8(bytes) .map(Incoming::Text) .map_err(|_| violation("text is not UTF-8")); diff --git a/openaide-rs/app-server/src/protocol_edge/local_http/listener/websocket_tests.rs b/openaide-rs/app-server/src/protocol_edge/local_http/listener/websocket_tests.rs index d872e81f..2d3823dd 100644 --- a/openaide-rs/app-server/src/protocol_edge/local_http/listener/websocket_tests.rs +++ b/openaide-rs/app-server/src/protocol_edge/local_http/listener/websocket_tests.rs @@ -89,9 +89,9 @@ fn rejects_a_continuation_without_a_message() { } #[test] -fn rejects_unmasked_binary_and_oversized_frames() { +fn rejects_unmasked_and_oversized_frames() { assert!(reader(vec![0x81, 0x02, b'h', b'i']).read().is_err()); - assert!(reader(client_frame(0x82, b"bin")).read().is_err()); + assert!(reader(vec![0x82, 0x02, 0xFF, 0x00]).read().is_err()); let mut small = MessageReader::new(Cursor::new(client_frame(0x81, b"too long")), 4); assert!(small.read().is_err()); } @@ -120,3 +120,13 @@ fn writes_unmasked_frames_with_the_shortest_length_encoding() { assert_eq!(&wire[..4], [0x88, 17, 0x11, 0x3A]); assert_eq!(&wire[4..], b"session_expired"); } + +#[test] +fn reads_binary_messages_without_utf8_validation() { + let mut bytes = client_frame(0x02, &[0xFF]); + bytes.extend(client_frame(0x80, &[0x00])); + assert_eq!( + reader(bytes).read().unwrap(), + Incoming::Binary(vec![0xFF, 0x00]) + ); +} diff --git a/openaide-rs/app-server/src/protocol_edge/responses.rs b/openaide-rs/app-server/src/protocol_edge/responses.rs index d7305b07..7f2811c2 100644 --- a/openaide-rs/app-server/src/protocol_edge/responses.rs +++ b/openaide-rs/app-server/src/protocol_edge/responses.rs @@ -189,6 +189,15 @@ pub fn update_shutdown_readiness_failed() -> ProtocolError { } } +pub fn replacement_impact_failed() -> ProtocolError { + ProtocolError { + code: ProtocolErrorCode::Internal, + message: "Could not determine what replacing the App Server would interrupt".to_string(), + recoverable: true, + target: None, + } +} + pub fn unsupported_method(method: &str) -> ProtocolError { ProtocolError { code: ProtocolErrorCode::InvalidRequest, diff --git a/openaide-rs/app-server/src/protocol_edge/routing.rs b/openaide-rs/app-server/src/protocol_edge/routing.rs index 03483d9d..27ed1d3b 100644 --- a/openaide-rs/app-server/src/protocol_edge/routing.rs +++ b/openaide-rs/app-server/src/protocol_edge/routing.rs @@ -8,28 +8,28 @@ use openaide_app_server_protocol::methods::{ ATTACHMENT_CREATE_PASTED_IMAGE, ATTACHMENT_LIST_DIRECTORY, ATTACHMENT_LIST_ROOTS, ATTACHMENT_REFRESH_HANDLES, ATTACHMENT_RELEASE, ATTACHMENT_REVEAL, ATTACHMENT_REVEAL_SENT, CLIENT_CAPABILITIES_CHANGED, CLIENT_DETACH, CLIENT_HEARTBEAT, CLIENT_INITIALIZE, CLIENT_PROBE, - CLIENT_UPDATE_SHUTDOWN_ABORT, CLIENT_UPDATE_SHUTDOWN_COMMIT, CLIENT_UPDATE_SHUTDOWN_PREPARE, - DIAGNOSTICS_CREATE_SUPPORT_EXPORT, DIAGNOSTICS_GET_RUNTIME, DIAGNOSTICS_LIST_SUPPORT_EXPORT, - FILE_VIEWER_CHANGES, FILE_VIEWER_DIFF, FILE_VIEWER_LIST_DIRECTORY, FILE_VIEWER_OPEN, - FILE_VIEWER_OPEN_FROM_HANDLE, FILE_VIEWER_REFRESH, FILE_VIEWER_RELEASE, FILE_VIEWER_SEARCH, - MCP_CREATE_SERVER, MCP_DELETE_SERVER, MCP_GET_SERVER_DETAILS, MCP_SET_SERVER_ENABLED, - MCP_UPDATE_SERVER, NATIVE_SESSION_ARCHIVE, NATIVE_SESSION_DELETE, NATIVE_SESSION_FORK, - NATIVE_SESSION_RESTORE, NATIVE_SESSION_SET_PINNED, NATIVE_SESSION_SET_TITLE, - PENDING_REQUEST_RESOLVE, PROJECT_ADD, PROJECT_REFRESH, PROJECT_REMOVE, PROJECT_RENAME, - SETTINGS_GET_AGENT_DETAILS, SETTINGS_GET_MCP_SERVERS, SETTINGS_GET_PREFERENCES, - SETTINGS_GET_RUNTIME, SETTINGS_GET_SKILLS, SETTINGS_GET_SKILL_DETAILS, - SETTINGS_RESET_TASK_HISTORY, SETTINGS_UPDATE_NEW_TASK_DEFAULTS, SETTINGS_UPDATE_PREFERENCES, - SETTINGS_UPDATE_RUNTIME, SHELL_RESOLVE_FILE_REVEAL, STATE_SUBSCRIBE, STATE_UNSUBSCRIBE, - SUPPORT_RECOVER_STUCK_SESSIONS, TASK_ACQUIRE, TASK_ACQUIRE_IN_WORKTREE, - TASK_ADOPT_NATIVE_SESSION, TASK_ARCHIVE, TASK_ARCHIVE_OLDER, TASK_ATTENTION_WAIT, TASK_CANCEL, - TASK_CHAT_PAGE, TASK_CLOSE_PLAN, TASK_COMPOSER_HISTORY, TASK_LIST, TASK_MARK_READ, - TASK_NAVIGATION_LOAD_MORE, TASK_NAVIGATION_REFRESH, TASK_OPEN, TASK_QUEUE_APPEND, - TASK_QUEUE_MOVE, TASK_QUEUE_REMOVE, TASK_QUEUE_TAKE, TASK_RELEASE, TASK_RELOAD_NATIVE_SESSION, - TASK_RESTORE, TASK_SEARCH_FILES, TASK_SEND, TASK_SET_CONFIG_OPTION, TASK_SET_PERMISSION_POLICY, - TASK_SET_PINNED, TASK_SET_TITLE, TASK_TOOL_IMAGE_PREVIEW, WORKSPACE_LIST_DIRECTORY, - WORKSPACE_LIST_ROOTS, WORKTREE_CREATE, WORKTREE_LINKED_TASKS, WORKTREE_RECREATE, - WORKTREE_REFRESH, WORKTREE_REMOVAL_PREFLIGHT, WORKTREE_REMOVE, WORKTREE_RENAME, - WORKTREE_RESOLVE_FOLDER, + CLIENT_REPLACEMENT_IMPACT, CLIENT_UPDATE_SHUTDOWN_ABORT, CLIENT_UPDATE_SHUTDOWN_COMMIT, + CLIENT_UPDATE_SHUTDOWN_PREPARE, DIAGNOSTICS_CREATE_SUPPORT_EXPORT, DIAGNOSTICS_GET_RUNTIME, + DIAGNOSTICS_LIST_SUPPORT_EXPORT, FILE_VIEWER_CHANGES, FILE_VIEWER_DIFF, + FILE_VIEWER_LIST_DIRECTORY, FILE_VIEWER_OPEN, FILE_VIEWER_OPEN_FROM_HANDLE, + FILE_VIEWER_REFRESH, FILE_VIEWER_RELEASE, FILE_VIEWER_SEARCH, MCP_CREATE_SERVER, + MCP_DELETE_SERVER, MCP_GET_SERVER_DETAILS, MCP_SET_SERVER_ENABLED, MCP_UPDATE_SERVER, + NATIVE_SESSION_ARCHIVE, NATIVE_SESSION_DELETE, NATIVE_SESSION_FORK, NATIVE_SESSION_RESTORE, + NATIVE_SESSION_SET_PINNED, NATIVE_SESSION_SET_TITLE, PENDING_REQUEST_RESOLVE, PROJECT_ADD, + PROJECT_REFRESH, PROJECT_REMOVE, PROJECT_RENAME, SETTINGS_GET_AGENT_DETAILS, + SETTINGS_GET_MCP_SERVERS, SETTINGS_GET_PREFERENCES, SETTINGS_GET_RUNTIME, SETTINGS_GET_SKILLS, + SETTINGS_GET_SKILL_DETAILS, SETTINGS_RESET_TASK_HISTORY, SETTINGS_UPDATE_NEW_TASK_DEFAULTS, + SETTINGS_UPDATE_PREFERENCES, SETTINGS_UPDATE_RUNTIME, SHELL_RESOLVE_FILE_REVEAL, + STATE_SUBSCRIBE, STATE_UNSUBSCRIBE, SUPPORT_RECOVER_STUCK_SESSIONS, TASK_ACQUIRE, + TASK_ACQUIRE_IN_WORKTREE, TASK_ADOPT_NATIVE_SESSION, TASK_ARCHIVE, TASK_ARCHIVE_OLDER, + TASK_ATTENTION_WAIT, TASK_CANCEL, TASK_CHAT_PAGE, TASK_CLOSE_PLAN, TASK_COMPOSER_HISTORY, + TASK_LIST, TASK_MARK_READ, TASK_NAVIGATION_LOAD_MORE, TASK_NAVIGATION_REFRESH, TASK_OPEN, + TASK_QUEUE_APPEND, TASK_QUEUE_MOVE, TASK_QUEUE_REMOVE, TASK_QUEUE_TAKE, TASK_RELEASE, + TASK_RELOAD_NATIVE_SESSION, TASK_RESTORE, TASK_SEARCH_FILES, TASK_SEND, TASK_SET_CONFIG_OPTION, + TASK_SET_PERMISSION_POLICY, TASK_SET_PINNED, TASK_SET_TITLE, TASK_TOOL_IMAGE_PREVIEW, + WORKSPACE_LIST_DIRECTORY, WORKSPACE_LIST_ROOTS, WORKTREE_CREATE, WORKTREE_LINKED_TASKS, + WORKTREE_RECREATE, WORKTREE_REFRESH, WORKTREE_REMOVAL_PREFLIGHT, WORKTREE_REMOVE, + WORKTREE_RENAME, WORKTREE_RESOLVE_FOLDER, }; use crate::client_lifecycle::{AppServerTime, ConnectionId}; @@ -157,6 +157,9 @@ impl RpcGateway { } CLIENT_HEARTBEAT => self.handle_client_heartbeat(connection_id, id, params, meta, now), CLIENT_DETACH => self.handle_client_detach(connection_id, id, params, meta, now), + CLIENT_REPLACEMENT_IMPACT => { + self.handle_client_replacement_impact(connection_id, id, params, meta) + } CLIENT_UPDATE_SHUTDOWN_PREPARE => { self.handle_update_shutdown_prepare(connection_id, id, params, meta) } @@ -286,6 +289,15 @@ impl RpcGateway { openaide_app_server_protocol::methods::DEVICES_REMOVE => { self.handle_devices_remove(connection_id, id, params, meta, now) } + openaide_app_server_protocol::methods::TERMINALS_OPEN => { + self.handle_terminals_open(connection_id, id, params, meta, now) + } + openaide_app_server_protocol::methods::TERMINALS_ATTACH => { + self.handle_terminals_attach(connection_id, id, params, meta) + } + openaide_app_server_protocol::methods::TERMINALS_CLOSE => { + self.handle_terminals_close(connection_id, id, params, meta, now) + } ATTACHMENT_LIST_ROOTS => { self.handle_attachment_list_roots(connection_id, id, params, meta) } diff --git a/openaide-rs/app-server/src/protocol_edge/shared_gateway.rs b/openaide-rs/app-server/src/protocol_edge/shared_gateway.rs index ad251c18..075df0a1 100644 --- a/openaide-rs/app-server/src/protocol_edge/shared_gateway.rs +++ b/openaide-rs/app-server/src/protocol_edge/shared_gateway.rs @@ -685,6 +685,18 @@ impl SharedRpcGateway { self.lock().publish_background_device_collection_update(now) } + pub fn terminals(&self) -> crate::terminals::Terminals { + self.lock_quiet().terminals.clone() + } + + pub fn publish_terminal_collection_update( + &self, + now: AppServerTime, + ) -> Vec { + self.lock() + .publish_background_terminal_collection_update(now) + } + pub fn publish_committed_task_update_for_connection( &self, connection_id: &ConnectionId, @@ -778,6 +790,9 @@ impl SharedRpcGateway { } pub fn shutdown(&self) -> Result { + // Terminals never keep App Server alive (ADR-0071); clients warn before stopping it. + // Close them outside the protocol lock. + self.terminals().close_all(); self.lock().shutdown() } diff --git a/openaide-rs/app-server/src/protocol_edge/terminal_handlers.rs b/openaide-rs/app-server/src/protocol_edge/terminal_handlers.rs new file mode 100644 index 00000000..000112db --- /dev/null +++ b/openaide-rs/app-server/src/protocol_edge/terminal_handlers.rs @@ -0,0 +1,167 @@ +//! Terminal lifecycle requests (ADR-0071). Bytes travel on the Terminal socket, not here. + +use std::path::PathBuf; + +use openaide_app_server_protocol::envelopes::RequestMeta; +use openaide_app_server_protocol::errors::{ProtocolError, ProtocolErrorCode}; +use openaide_app_server_protocol::events::{AppServerEventPayload, EventScope}; +use openaide_app_server_protocol::ids::ProjectId; +use openaide_app_server_protocol::terminals::{ + TerminalTarget, TerminalsAttachParams, TerminalsAttachResult, TerminalsCloseParams, + TerminalsCloseResult, TerminalsOpenParams, TerminalsOpenResult, +}; +use serde_json::Value; + +use crate::client_lifecycle::{AppServerTime, ConnectionId}; + +use super::{event_deliveries, responses, GatewayEventDelivery, GatewayOutcome, RpcGateway}; + +impl RpcGateway { + pub(super) fn handle_terminals_open( + &mut self, + connection_id: ConnectionId, + id: String, + params: Value, + meta: RequestMeta, + now: AppServerTime, + ) -> GatewayOutcome { + let params = match serde_json::from_value::(params) { + Ok(params) => params, + Err(error) => { + return self.error(connection_id, id, meta, responses::invalid_params(error)) + } + }; + let opened = self + .terminal_folder(&connection_id, ¶ms.target) + .and_then(|(project_id, folder, target_kind)| { + self.terminals + .open(project_id, &folder, params.cols, params.rows, target_kind) + }); + match opened { + Ok(opened) => { + let events = self.publish_terminal_collection_update(now); + let result = TerminalsOpenResult { + terminal: opened.terminal, + attach: opened.attach, + }; + self.result_with_events(connection_id, id, meta, result, events) + } + Err(error) => self.error(connection_id, id, meta, error), + } + } + + pub(super) fn handle_terminals_attach( + &mut self, + connection_id: ConnectionId, + id: String, + params: Value, + meta: RequestMeta, + ) -> GatewayOutcome { + let params = match serde_json::from_value::(params) { + Ok(params) => params, + Err(error) => { + return self.error(connection_id, id, meta, responses::invalid_params(error)) + } + }; + match self.terminals.attach(¶ms.terminal_id) { + Ok(attach) => self.result(connection_id, id, meta, TerminalsAttachResult { attach }), + Err(error) => self.error(connection_id, id, meta, error), + } + } + + pub(super) fn handle_terminals_close( + &mut self, + connection_id: ConnectionId, + id: String, + params: Value, + meta: RequestMeta, + now: AppServerTime, + ) -> GatewayOutcome { + let params = match serde_json::from_value::(params) { + Ok(params) => params, + Err(error) => { + return self.error(connection_id, id, meta, responses::invalid_params(error)) + } + }; + match self.terminals.close(¶ms.terminal_id, params.force) { + Ok(()) => { + let events = self.publish_terminal_collection_update(now); + self.result_with_events(connection_id, id, meta, TerminalsCloseResult {}, events) + } + Err(error) => self.error(connection_id, id, meta, error), + } + } + + /// For status changes a shell reports from its own thread. + pub(crate) fn publish_background_terminal_collection_update( + &mut self, + now: AppServerTime, + ) -> Vec { + let events = self.publish_terminal_collection_update(now); + // Links drain background publications on their next wake. + self.pending_event_deliveries.extend(events.clone()); + events + } + + fn publish_terminal_collection_update( + &mut self, + now: AppServerTime, + ) -> Vec { + let terminals = self.terminals.snapshot(); + let client_hub = self.client_hub.clone(); + event_deliveries(self.state_stream.publish_committed( + EventScope::StateRoot { + state_root_id: self.state_stream.state_root_id().clone(), + }, + AppServerEventPayload::TerminalCollectionUpdated { terminals }, + |client_id| client_hub.delivery_for(client_id), + now, + )) + } + + /// Resolves the start folder on App Server; a client never supplies a path. + fn terminal_folder( + &self, + connection_id: &ConnectionId, + target: &TerminalTarget, + ) -> Result<(ProjectId, PathBuf, &'static str), ProtocolError> { + match target { + TerminalTarget::Project { project_id } => self + .project_roots + .projects() + .into_iter() + .find(|project| &project.project_id == project_id) + .map(|project| { + ( + project.project_id, + PathBuf::from(project.workspace_root), + "project", + ) + }) + .ok_or_else(|| ProtocolError { + code: ProtocolErrorCode::NotFound, + message: "Project is not open".to_string(), + recoverable: true, + target: None, + }), + TerminalTarget::Task { task_id } => { + let client = self + .client_hub + .context_for_connection(connection_id) + .ok_or_else(|| { + responses::not_initialized( + openaide_app_server_protocol::methods::TERMINALS_OPEN.to_string(), + ) + })?; + let (workspace_root, project_root) = self + .task_snapshots + .terminal_folder_for_client(&client.client_instance_id, task_id)?; + Ok(( + crate::projects::project_id_for_workspace(&project_root), + PathBuf::from(workspace_root), + "task", + )) + } + } + } +} diff --git a/openaide-rs/app-server/src/protocol_edge/tests.rs b/openaide-rs/app-server/src/protocol_edge/tests.rs index c85fbcec..b320ee5b 100644 --- a/openaide-rs/app-server/src/protocol_edge/tests.rs +++ b/openaide-rs/app-server/src/protocol_edge/tests.rs @@ -73,6 +73,8 @@ use super::*; mod client_probe; mod devices; mod gateway_lock_wait; +mod replacement_impact; +mod terminals; #[test] fn rejects_product_request_before_initialize() { diff --git a/openaide-rs/app-server/src/protocol_edge/tests/replacement_impact.rs b/openaide-rs/app-server/src/protocol_edge/tests/replacement_impact.rs new file mode 100644 index 00000000..ce2af9cc --- /dev/null +++ b/openaide-rs/app-server/src/protocol_edge/tests/replacement_impact.rs @@ -0,0 +1,117 @@ +use openaide_app_server_protocol::client::ClientReplacementImpactParams; +use openaide_app_server_protocol::methods::CLIENT_REPLACEMENT_IMPACT; +use openaide_app_server_protocol::snapshot::ProtocolVersion; +use serde_json::json; + +use super::*; + +#[test] +fn reports_other_clients_and_active_work_without_starting_a_replacement() { + let mut gateway = gateway(); + gateway.shutdown = Arc::new(FixedShutdownWithBlockers { + active_turns: 2, + pending_task_requests: 1, + }); + initialize_client(&mut gateway, ConnectionId::new("conn-1")); + let mut phone = init_params("client-2"); + phone.shell = ShellDescriptor { + kind: ShellKind::Mobile, + name: Some("Pixel".to_string()), + version: Some("0.9.0".to_string()), + }; + phone.protocol_version = Some(ProtocolVersion { major: 2, minor: 1 }); + response_value(gateway.handle_inbound( + ConnectionId::new("conn-2"), + request("2", CLIENT_INITIALIZE, phone), + AppServerTime(2), + )); + let mut terminal = init_params("client-3"); + terminal.shell = ShellDescriptor { + kind: ShellKind::Terminal, + name: None, + version: Some("0.10.0".to_string()), + }; + response_value(gateway.handle_inbound( + ConnectionId::new("conn-3"), + request("3", CLIENT_INITIALIZE, terminal), + AppServerTime(3), + )); + gateway.handle_transport_closed(&ConnectionId::new("conn-3"), AppServerTime(4)); + + let impact = response_value(gateway.handle_inbound( + ConnectionId::new("conn-1"), + request( + "4", + CLIENT_REPLACEMENT_IMPACT, + ClientReplacementImpactParams {}, + ), + AppServerTime(5), + )); + + assert_eq!( + impact["result"], + json!({ + "otherClients": [ + { + "kind": "mobile", + "name": "Pixel", + "appVersion": "0.9.0", + "protocolVersion": { "major": 2, "minor": 1 }, + "reconnecting": false + }, + { "kind": "terminal", "appVersion": "0.10.0", "reconnecting": true } + ], + "activeTurns": 2, + "pendingTaskRequests": 1, + "openTerminals": 0 + }) + ); + response_value(gateway.handle_inbound( + ConnectionId::new("conn-2"), + request( + "5", + STATE_SUBSCRIBE, + StateSubscribeParams { + scope: SubscriptionScope::Projects, + }, + ), + AppServerTime(6), + )); +} + +#[test] +fn a_lone_idle_client_sees_no_impact() { + let mut gateway = initialized_gateway("client-1", "conn-1"); + + let impact = response_value(gateway.handle_inbound( + ConnectionId::new("conn-1"), + request( + "2", + CLIENT_REPLACEMENT_IMPACT, + ClientReplacementImpactParams {}, + ), + AppServerTime(2), + )); + + assert_eq!( + impact["result"], + json!({ "activeTurns": 0, "pendingTaskRequests": 0, "openTerminals": 0 }) + ); +} + +#[test] +fn requires_an_initialized_client() { + let mut gateway = gateway(); + + let error = response_error(gateway.handle_inbound( + ConnectionId::new("conn-1"), + request( + "1", + CLIENT_REPLACEMENT_IMPACT, + ClientReplacementImpactParams {}, + ), + AppServerTime(1), + )); + + assert_eq!(error.error.code, ProtocolErrorCode::NotInitialized); +} diff --git a/openaide-rs/app-server/src/protocol_edge/tests/terminals.rs b/openaide-rs/app-server/src/protocol_edge/tests/terminals.rs new file mode 100644 index 00000000..80a5b233 --- /dev/null +++ b/openaide-rs/app-server/src/protocol_edge/tests/terminals.rs @@ -0,0 +1,180 @@ +use openaide_app_server_protocol::client::{ + ClientReplacementImpactParams, UpdateShutdownPrepareParams, +}; +use openaide_app_server_protocol::events::AppServerEventPayload; +use openaide_app_server_protocol::methods::{ + CLIENT_REPLACEMENT_IMPACT, TERMINALS_CLOSE, TERMINALS_OPEN, +}; +use openaide_app_server_protocol::terminals::{ + TerminalTarget, TerminalsCloseParams, TerminalsOpenParams, +}; +use serde_json::json; + +use super::*; + +fn subscribe_to_terminals(gateway: &mut RpcGateway, connection_id: &ConnectionId) -> Value { + response_value(gateway.handle_inbound( + connection_id.clone(), + request( + "subscribe", + STATE_SUBSCRIBE, + StateSubscribeParams { + scope: SubscriptionScope::Terminals, + }, + ), + AppServerTime(2), + )) +} + +#[cfg(unix)] +#[test] +fn a_terminal_opens_in_its_project_and_every_subscriber_sees_it_close() { + let mut gateway = gateway_with_project_context(); + let folder = tempfile::tempdir().unwrap(); + let project = gateway + .project_roots + .add_project(folder.path().to_str().unwrap()) + .unwrap(); + let connection_id = ConnectionId::new("conn-1"); + initialize_client(&mut gateway, connection_id.clone()); + let empty = subscribe_to_terminals(&mut gateway, &connection_id); + assert_eq!( + empty["result"]["snapshot"], + json!({ "kind": "terminals", "terminals": { "terminals": [] } }) + ); + + let outcome = gateway.handle_inbound( + connection_id.clone(), + request( + "open", + TERMINALS_OPEN, + TerminalsOpenParams { + target: TerminalTarget::Project { + project_id: project.project_id.clone(), + }, + cols: 80, + rows: 24, + }, + ), + AppServerTime(3), + ); + let events = response_events(outcome.clone()); + let opened = response_value(outcome); + let terminal_id = opened["result"]["terminal"]["terminalId"] + .as_str() + .unwrap() + .to_string(); + assert_eq!( + opened["result"]["terminal"]["projectId"], + json!(project.project_id) + ); + assert_eq!(opened["result"]["attach"]["path"], json!("/terminal")); + let [delivery] = events.as_slice() else { + panic!("expected one terminal collection event, got {events:?}"); + }; + let AppServerEventPayload::TerminalCollectionUpdated { terminals } = &delivery.event.payload + else { + panic!("unexpected payload {:?}", delivery.event.payload); + }; + assert_eq!(terminals.terminals[0].terminal_id, terminal_id); + + let events = response_events(gateway.handle_inbound( + connection_id, + request( + "close", + TERMINALS_CLOSE, + TerminalsCloseParams { + terminal_id, + force: false, + }, + ), + AppServerTime(4), + )); + let [delivery] = events.as_slice() else { + panic!("expected one terminal collection event, got {events:?}"); + }; + assert!(matches!( + &delivery.event.payload, + AppServerEventPayload::TerminalCollectionUpdated { terminals } if terminals.terminals.is_empty() + )); +} + +#[test] +fn an_unknown_project_is_not_found() { + let mut gateway = gateway_with_project_context(); + let connection_id = ConnectionId::new("conn-1"); + initialize_client(&mut gateway, connection_id.clone()); + + let outcome = gateway.handle_inbound( + connection_id, + request( + "open", + TERMINALS_OPEN, + TerminalsOpenParams { + target: TerminalTarget::Project { + project_id: "missing".to_string().into(), + }, + cols: 80, + rows: 24, + }, + ), + AppServerTime(3), + ); + assert!(gateway.terminals.snapshot().terminals.is_empty()); + assert_eq!( + response_error(outcome).error.code, + ProtocolErrorCode::NotFound + ); +} + +#[cfg(unix)] +#[test] +fn an_open_terminal_is_reported_and_needs_confirmation_before_an_update() { + let mut gateway = gateway(); + initialize_client(&mut gateway, ConnectionId::new("conn-1")); + gateway + .terminals + .open( + "project".to_string().into(), + std::path::Path::new("/"), + 80, + 24, + "project", + ) + .unwrap(); + + let impact = response_value(gateway.handle_inbound( + ConnectionId::new("conn-1"), + request( + "2", + CLIENT_REPLACEMENT_IMPACT, + ClientReplacementImpactParams {}, + ), + AppServerTime(2), + )); + assert_eq!(impact["result"]["openTerminals"], json!(1)); + + let prepare = |gateway: &mut RpcGateway, id: &str, stop_active_work: bool| { + response_value(gateway.handle_inbound( + ConnectionId::new("conn-1"), + request( + id, + CLIENT_UPDATE_SHUTDOWN_PREPARE, + UpdateShutdownPrepareParams { + attempt_id: "update-1".to_string(), + stop_active_work, + }, + ), + AppServerTime(3), + )) + }; + assert_eq!( + prepare(&mut gateway, "3", false)["result"], + json!({ "kind": "blocked", "reason": "activeWork" }) + ); + assert_eq!( + prepare(&mut gateway, "4", true)["result"], + json!({ "kind": "ready" }) + ); + gateway.terminals.close_all(); +} diff --git a/openaide-rs/app-server/src/pty_child.rs b/openaide-rs/app-server/src/pty_child.rs new file mode 100644 index 00000000..fc88a297 --- /dev/null +++ b/openaide-rs/app-server/src/pty_child.rs @@ -0,0 +1,67 @@ +//! A process App Server runs on a pseudo-terminal, shared by Agent sign-in and Terminals. + +use portable_pty::{Child, ExitStatus, PtySize}; + +pub(crate) fn pty_size(cols: u16, rows: u16) -> PtySize { + PtySize { + cols, + rows, + pixel_width: 0, + pixel_height: 0, + } +} + +/// Owns a PTY child and its process group. Teardown is unconditional: PTY launch creates a fresh +/// session and process group, and launchers can exit while a child they started stays alive, so +/// dropping a running child kills the whole group before killing and reaping the child itself. +pub(crate) struct PtyChild { + child: Box, + running: bool, +} + +impl PtyChild { + pub(crate) fn new(child: Box) -> Self { + Self { + child, + running: true, + } + } + + pub(crate) fn process_id(&self) -> Option { + self.child.process_id() + } + + /// Returns the exit status once the child has exited. The group is killed then, and the PID + /// is never signalled again: it has been reaped and the OS may reuse it. + pub(crate) fn try_exit(&mut self) -> std::io::Result> { + if !self.running { + return Ok(None); + } + let Some(status) = self.child.try_wait()? else { + return Ok(None); + }; + self.kill_group(); + self.running = false; + Ok(Some(status)) + } + + fn kill_group(&self) { + #[cfg(unix)] + if let Some(pid) = self.child.process_id() { + let _ = nix::sys::signal::killpg( + nix::unistd::Pid::from_raw(pid as i32), + nix::sys::signal::Signal::SIGKILL, + ); + } + } +} + +impl Drop for PtyChild { + fn drop(&mut self) { + if self.running { + self.kill_group(); + let _ = self.child.kill(); + let _ = self.child.wait(); + } + } +} diff --git a/openaide-rs/app-server/src/remote_command.rs b/openaide-rs/app-server/src/remote_command.rs index dc4f3873..1d2d3b77 100644 --- a/openaide-rs/app-server/src/remote_command.rs +++ b/openaide-rs/app-server/src/remote_command.rs @@ -19,8 +19,8 @@ use openaide_app_server_protocol::devices::{ use openaide_app_server_protocol::methods::{DEVICES_CREATE_INVITE, DEVICES_REMOVE}; use serde_json::json; -mod launch; -mod local_client; +pub(crate) mod launch; +pub(crate) mod local_client; mod text; use launch::Connection; diff --git a/openaide-rs/app-server/src/remote_command/launch.rs b/openaide-rs/app-server/src/remote_command/launch.rs index 56b87553..3644e66e 100644 --- a/openaide-rs/app-server/src/remote_command/launch.rs +++ b/openaide-rs/app-server/src/remote_command/launch.rs @@ -57,6 +57,13 @@ pub(crate) fn attach_or_start( if let Some(connection) = attach(state_root, runtime_root) { return Ok(connection); } + start() +} + +/// Starts an App Server for the default state root in a separate process and reads +/// the address it prints. If another one won the launch meanwhile, the started +/// process prints that one's address instead and exits. +pub(crate) fn start() -> Result { let executable = std::env::current_exe().map_err(|_| LaunchError::Spawn)?; let mut command = Command::new(executable); command diff --git a/openaide-rs/app-server/src/remote_command/local_client.rs b/openaide-rs/app-server/src/remote_command/local_client.rs index c6bbe96b..75d477e6 100644 --- a/openaide-rs/app-server/src/remote_command/local_client.rs +++ b/openaide-rs/app-server/src/remote_command/local_client.rs @@ -44,6 +44,7 @@ pub(crate) struct Answer { } pub(crate) struct LocalClient { + kind: ShellKind, address: SocketAddr, path: String, auth_token: String, @@ -55,6 +56,16 @@ pub(crate) struct LocalClient { impl LocalClient { /// Reads the loopback address the App Server published. Any other host is refused. pub(crate) fn new(endpoint_url: &str, auth_token: &str) -> Result { + Self::with_kind(ShellKind::Terminal, endpoint_url, auth_token) + } + + /// A client that introduces itself as `kind`: `terminal` for the `remote` + /// command, `keeper` for the keeper on an SSH Computer. + pub(crate) fn with_kind( + kind: ShellKind, + endpoint_url: &str, + auth_token: &str, + ) -> Result { let rest = endpoint_url .strip_prefix("http://") .ok_or(ClientError::Malformed)?; @@ -67,12 +78,17 @@ impl LocalClient { return Err(ClientError::Malformed); } let instance = uuid::Uuid::new_v4().simple().to_string(); + let prefix = match kind { + ShellKind::Keeper => "keeper", + _ => "terminal", + }; Ok(Self { + kind, address, path: path.to_string(), auth_token: auth_token.to_string(), - connection_id: format!("terminal-{instance}"), - client_instance_id: format!("terminal-{instance}"), + connection_id: format!("{prefix}-{instance}"), + client_instance_id: format!("{prefix}-{instance}"), next_request: 0, }) } @@ -83,7 +99,7 @@ impl LocalClient { json!({ "clientInstanceId": self.client_instance_id, "shell": { - "kind": ShellKind::Terminal, + "kind": self.kind, "name": "OpenAIDE", "version": env!("CARGO_PKG_VERSION"), }, diff --git a/openaide-rs/app-server/src/snapshots.rs b/openaide-rs/app-server/src/snapshots.rs index 4404e4e2..b4effc92 100644 --- a/openaide-rs/app-server/src/snapshots.rs +++ b/openaide-rs/app-server/src/snapshots.rs @@ -69,6 +69,7 @@ pub struct SnapshotBuilder { task_navigation: Arc, task_snapshots: Arc, devices: Arc, + terminals: crate::terminals::Terminals, } /// Groups snapshot projections so adding one source does not widen every construction call. @@ -166,6 +167,7 @@ impl SnapshotBuilder { task_navigation: sources.task_navigation, task_snapshots: sources.task_snapshots, devices: Arc::new(crate::remote_devices::NoRemoteDevices), + terminals: crate::terminals::Terminals::default(), } } @@ -177,6 +179,12 @@ impl SnapshotBuilder { self } + /// The gateway and subscriptions read one registry, so a response and a snapshot agree. + pub(crate) fn with_terminals(mut self, terminals: crate::terminals::Terminals) -> Self { + self.terminals = terminals; + self + } + pub fn client_snapshot( &self, ctx: &ClientContext, @@ -265,6 +273,9 @@ impl SnapshotProvider for SnapshotBuilder { SubscriptionScope::Devices => SubscriptionSnapshot::Devices { devices: self.devices.snapshot(), }, + SubscriptionScope::Terminals => SubscriptionSnapshot::Terminals { + terminals: self.terminals.snapshot(), + }, SubscriptionScope::Agents => SubscriptionSnapshot::Agents { agents: self.agents.snapshot()?, }, diff --git a/openaide-rs/app-server/src/snapshots/task_snapshot.rs b/openaide-rs/app-server/src/snapshots/task_snapshot.rs index 98f565b5..b82d6553 100644 --- a/openaide-rs/app-server/src/snapshots/task_snapshot.rs +++ b/openaide-rs/app-server/src/snapshots/task_snapshot.rs @@ -133,6 +133,22 @@ pub trait TaskSnapshotSource: Send + Sync { target: None, }) } + + /// Resolves where a Terminal opened from a Task starts: its Task Workspace, and the Project + /// root that identifies its Project. + fn terminal_folder_for_client( + &self, + client_instance_id: &ClientInstanceId, + task_id: &TaskId, + ) -> Result<(String, String), ProtocolError> { + let _ = (client_instance_id, task_id); + Err(ProtocolError { + code: ProtocolErrorCode::NotFound, + message: "Task is unavailable".to_string(), + recoverable: true, + target: None, + }) + } } /// Supplies process-local history reconciliation state for otherwise durable Task snapshots. @@ -389,6 +405,24 @@ impl TaskSnapshotSource for TaskSnapshotStore { .map_err(task_snapshot_error)?; Ok(task.workspace_root) } + + fn terminal_folder_for_client( + &self, + client_instance_id: &ClientInstanceId, + task_id: &TaskId, + ) -> Result<(String, String), ProtocolError> { + let task = self + .store + .read_task(task_id.as_str()) + .map_err(task_snapshot_error)?; + crate::tasks::access::require_client_task_access(&task, client_instance_id) + .map_err(task_snapshot_error)?; + let project_root = task + .project_root + .clone() + .unwrap_or_else(|| task.workspace_root.clone()); + Ok((task.workspace_root, project_root)) + } } impl TaskSnapshotStore { diff --git a/openaide-rs/app-server/src/state_sync.rs b/openaide-rs/app-server/src/state_sync.rs index e08b9943..f583c02c 100644 --- a/openaide-rs/app-server/src/state_sync.rs +++ b/openaide-rs/app-server/src/state_sync.rs @@ -411,6 +411,11 @@ fn payload_matches_subscription( AppServerEventPayload::SnapshotReplaced { .. } | AppServerEventPayload::DeviceCollectionUpdated { .. } ), + SubscriptionScope::Terminals => matches!( + payload, + AppServerEventPayload::TerminalCollectionUpdated { .. } + | AppServerEventPayload::SnapshotReplaced { .. } + ), SubscriptionScope::TaskNavigation { section, project_ids, diff --git a/openaide-rs/app-server/src/terminals.rs b/openaide-rs/app-server/src/terminals.rs new file mode 100644 index 00000000..629363ee --- /dev/null +++ b/openaide-rs/app-server/src/terminals.rs @@ -0,0 +1,322 @@ +//! Terminals: the user's own shells on the App Server's computer (ADR-0071). +//! +//! App Server owns every shell. A Terminal outlives the clients viewing it and ends when closed, +//! when its shell exits and the user closes it, or when App Server stops. Nothing here is +//! persisted, and Agents never see a Terminal. +//! +//! Lifecycle requests come through the protocol gateway, which publishes the list itself. Status +//! changes a shell reports on its own reach clients through `on_changed`, which never runs while +//! a gateway call into this registry is in progress. + +mod replay; +mod shell; + +use std::collections::HashMap; +use std::path::Path; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use openaide_app_server_protocol::errors::{ProtocolError, ProtocolErrorCode}; +use openaide_app_server_protocol::ids::ProjectId; +use openaide_app_server_protocol::terminals::{ + TerminalAttach, TerminalCollectionSnapshot, TerminalStatus, TerminalSummary, +}; +use serde_json::json; + +pub(crate) use shell::{valid_size, Shell}; + +/// Path suffix the local HTTP listener routes to a Terminal socket. +pub const TERMINAL_SOCKET_PATH: &str = "/terminal"; +/// Long enough for a client to open the socket after the response, short enough that a leaked +/// token is useless soon after. +const ATTACH_TOKEN_TTL: Duration = Duration::from_secs(30); + +type ChangedCallback = Arc; + +#[derive(Clone, Default)] +pub struct Terminals { + inner: Arc>, + changed: Arc>>, +} + +#[derive(Default)] +struct Inner { + /// Ordered by opening, so every client lists tabs the same way. + shells: Vec>, + attach_tokens: HashMap, +} + +struct PendingAttach { + terminal_id: String, + expires_at: Instant, +} + +/// What the gateway publishes after a lifecycle change. +pub(crate) struct Opened { + pub(crate) terminal: TerminalSummary, + pub(crate) attach: TerminalAttach, +} + +impl Terminals { + /// Runs on a shell's monitor thread whenever a Terminal's published status changes. + pub fn on_changed(&self, changed: impl Fn() + Send + Sync + 'static) { + *self.changed.lock().expect("terminal callback poisoned") = Some(Arc::new(changed)); + } + + pub fn snapshot(&self) -> TerminalCollectionSnapshot { + let shells = self + .inner + .lock() + .expect("terminals poisoned") + .shells + .clone(); + TerminalCollectionSnapshot { + terminals: shells.iter().map(|shell| shell.summary()).collect(), + } + } + + /// Running Terminals, which stopping App Server would end. + pub fn open_count(&self) -> usize { + self.inner + .lock() + .expect("terminals poisoned") + .shells + .iter() + .filter(|shell| shell.summary().status != TerminalStatus::Exited) + .count() + } + + pub(crate) fn open( + &self, + project_id: ProjectId, + folder: &Path, + cols: u16, + rows: u16, + target_kind: &'static str, + ) -> Result { + if !valid_size(cols, rows) { + return Err(error( + ProtocolErrorCode::ValidationFailed, + "Terminal size is out of range", + )); + } + let started = Instant::now(); + let terminal_id = format!("terminal_{}", uuid::Uuid::new_v4().simple()); + crate::logging::info( + "terminal_open_started", + json!({ "terminal_id": terminal_id, "target_kind": target_kind, "attempt": 1 }), + ); + let changed = self.changed.clone(); + let spawned = Shell::spawn( + terminal_id.clone(), + project_id, + folder, + cols, + rows, + unix_millis(), + move |shell| { + log_status_change(shell); + let callback = changed.lock().expect("terminal callback poisoned").clone(); + if let Some(callback) = callback { + callback(); + } + }, + ); + let shell = match spawned { + Ok(shell) => shell, + Err(spawn_error) => { + crate::logging::warn( + "terminal_open_completed", + json!({ + "terminal_id": terminal_id, + "target_kind": target_kind, + "outcome": "failure", + "error_kind": format!("{:?}", spawn_error.kind()), + "duration_ms": started.elapsed().as_millis(), + "attempt": 1, + }), + ); + return Err(error( + ProtocolErrorCode::Internal, + "The shell could not be started", + )); + } + }; + let terminal = shell.summary(); + let mut inner = self.inner.lock().expect("terminals poisoned"); + inner.shells.push(shell); + let attach = inner.issue_attach(&terminal_id); + let open = inner.shells.len(); + drop(inner); + crate::logging::info( + "terminal_open_completed", + json!({ + "terminal_id": terminal_id, + "target_kind": target_kind, + "outcome": "success", + "terminals_open": open, + "duration_ms": started.elapsed().as_millis(), + "attempt": 1, + }), + ); + Ok(Opened { terminal, attach }) + } + + pub(crate) fn attach(&self, terminal_id: &str) -> Result { + let mut inner = self.inner.lock().expect("terminals poisoned"); + let Some(shell) = inner.shell(terminal_id) else { + crate::logging::warn( + "terminal_attach_issued", + json!({ "terminal_id": terminal_id, "outcome": "not_found" }), + ); + return Err(not_found()); + }; + let attach = inner.issue_attach(terminal_id); + drop(inner); + // Pairs with `terminal_socket_opened` or `terminal_socket_rejected` for the same id. + crate::logging::info( + "terminal_attach_issued", + json!({ + "terminal_id": terminal_id, + "outcome": "success", + "viewers": shell.viewer_count(), + }), + ); + Ok(attach) + } + + /// Fails with `conflict` when a command is running and the user has not confirmed. + pub(crate) fn close(&self, terminal_id: &str, force: bool) -> Result<(), ProtocolError> { + let mut inner = self.inner.lock().expect("terminals poisoned"); + let Some(shell) = inner.shell(terminal_id) else { + return Err(not_found()); + }; + let busy = shell.is_busy(); + if busy && !force { + return Err(error( + ProtocolErrorCode::Conflict, + "A command is running in this Terminal", + )); + } + inner + .shells + .retain(|shell| shell.terminal_id != terminal_id); + inner + .attach_tokens + .retain(|_, pending| pending.terminal_id != terminal_id); + let open = inner.shells.len(); + drop(inner); + let status = shell.summary().status; + let viewers = shell.viewer_count(); + shell.terminate(); + crate::logging::info( + "terminal_closed", + json!({ + "terminal_id": terminal_id, + "status": status, + "forced": busy, + "viewers": viewers, + "terminals_open": open, + }), + ); + Ok(()) + } + + /// Ends every shell, for an App Server stop. A process exit also hangs up each PTY, which + /// ends the shells, but closing here reaps them and records the closes. + pub fn close_all(&self) { + let shells = std::mem::take(&mut self.inner.lock().expect("terminals poisoned").shells); + if shells.is_empty() { + return; + } + for shell in &shells { + shell.terminate(); + } + crate::logging::info("terminals_closed_all", json!({ "terminals": shells.len() })); + } + + /// Redeems a single-use attach token for the socket that presents it. + pub(crate) fn claim(&self, token: &str) -> Option> { + let mut inner = self.inner.lock().expect("terminals poisoned"); + let now = Instant::now(); + inner + .attach_tokens + .retain(|_, pending| pending.expires_at > now); + let pending = inner.attach_tokens.remove(token)?; + inner.shell(&pending.terminal_id) + } +} + +impl Inner { + fn shell(&self, terminal_id: &str) -> Option> { + self.shells + .iter() + .find(|shell| shell.terminal_id == terminal_id) + .cloned() + } + + fn issue_attach(&mut self, terminal_id: &str) -> TerminalAttach { + let now = Instant::now(); + self.attach_tokens + .retain(|_, pending| pending.expires_at > now); + let token = format!( + "{}{}", + uuid::Uuid::new_v4().simple(), + uuid::Uuid::new_v4().simple() + ); + self.attach_tokens.insert( + token.clone(), + PendingAttach { + terminal_id: terminal_id.to_string(), + expires_at: now + ATTACH_TOKEN_TTL, + }, + ); + TerminalAttach { + terminal_id: terminal_id.to_string(), + token, + path: TERMINAL_SOCKET_PATH.to_string(), + } + } +} + +fn log_status_change(shell: &Shell) { + let summary = shell.summary(); + let event = if summary.status == TerminalStatus::Exited { + "terminal_shell_exited" + } else { + "terminal_status_changed" + }; + // The command name and folder are user content and stay out of logs. + crate::logging::info( + event, + json!({ + "terminal_id": summary.terminal_id, + "status": summary.status, + "exit_code": summary.exit_code, + }), + ); +} + +fn unix_millis() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|elapsed| u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX)) + .unwrap_or_default() +} + +fn not_found() -> ProtocolError { + error(ProtocolErrorCode::NotFound, "Terminal is not open") +} + +fn error(code: ProtocolErrorCode, message: &str) -> ProtocolError { + ProtocolError { + code, + message: message.to_string(), + recoverable: true, + target: None, + } +} + +#[cfg(all(test, unix))] +#[path = "terminals_tests.rs"] +mod tests; diff --git a/openaide-rs/app-server/src/terminals/replay.rs b/openaide-rs/app-server/src/terminals/replay.rs new file mode 100644 index 00000000..23645d54 --- /dev/null +++ b/openaide-rs/app-server/src/terminals/replay.rs @@ -0,0 +1,61 @@ +//! Retained Terminal output for clients that attach later (ADR-0071). + +use std::collections::VecDeque; + +/// About 10,000 lines of an ordinary shell session. +pub(crate) const REPLAY_MAX_LINES: usize = 10_000; +/// Bounds memory when lines are long or output has no newlines at all. +pub(crate) const REPLAY_MAX_BYTES: usize = 2 * 1024 * 1024; + +/// Keeps the newest output within both limits. Trimming cuts after a newline so a replay never +/// starts in the middle of a line or of the escape sequence that styles it; output without any +/// newline falls back to a byte cut. +pub(crate) struct Replay { + bytes: VecDeque, + lines: usize, + max_lines: usize, + max_bytes: usize, +} + +impl Default for Replay { + fn default() -> Self { + Self::with_limits(REPLAY_MAX_LINES, REPLAY_MAX_BYTES) + } +} + +impl Replay { + pub(crate) fn with_limits(max_lines: usize, max_bytes: usize) -> Self { + Self { + bytes: VecDeque::new(), + lines: 0, + max_lines, + max_bytes, + } + } + + pub(crate) fn push(&mut self, output: &[u8]) { + self.lines += output.iter().filter(|byte| **byte == b'\n').count(); + self.bytes.extend(output); + while self.lines > self.max_lines || self.bytes.len() > self.max_bytes { + match self.bytes.iter().position(|byte| *byte == b'\n') { + Some(newline) => { + self.bytes.drain(..=newline); + self.lines -= 1; + } + None => { + let excess = self.bytes.len().saturating_sub(self.max_bytes); + self.bytes.drain(..excess); + break; + } + } + } + } + + pub(crate) fn contents(&self) -> Vec { + self.bytes.iter().copied().collect() + } +} + +#[cfg(test)] +#[path = "replay_tests.rs"] +mod tests; diff --git a/openaide-rs/app-server/src/terminals/replay_tests.rs b/openaide-rs/app-server/src/terminals/replay_tests.rs new file mode 100644 index 00000000..bd114175 --- /dev/null +++ b/openaide-rs/app-server/src/terminals/replay_tests.rs @@ -0,0 +1,30 @@ +use super::Replay; + +#[test] +fn keeps_everything_within_limits() { + let mut replay = Replay::with_limits(3, 100); + replay.push(b"one\ntwo\n"); + replay.push(b"thr"); + assert_eq!(replay.contents(), b"one\ntwo\nthr"); +} + +#[test] +fn drops_whole_oldest_lines_past_the_newline_limit() { + let mut replay = Replay::with_limits(2, 100); + replay.push(b"one\ntwo\nthree\nfour"); + assert_eq!(replay.contents(), b"two\nthree\nfour"); +} + +#[test] +fn cuts_at_a_newline_past_the_byte_limit() { + let mut replay = Replay::with_limits(100, 10); + replay.push(b"aaaa\nbbbb\ncccc"); + assert_eq!(replay.contents(), b"bbbb\ncccc"); +} + +#[test] +fn cuts_bytes_when_output_has_no_newline() { + let mut replay = Replay::with_limits(100, 4); + replay.push(b"abcdefgh"); + assert_eq!(replay.contents(), b"efgh"); +} diff --git a/openaide-rs/app-server/src/terminals/shell.rs b/openaide-rs/app-server/src/terminals/shell.rs new file mode 100644 index 00000000..83eb63fd --- /dev/null +++ b/openaide-rs/app-server/src/terminals/shell.rs @@ -0,0 +1,462 @@ +//! One Terminal's shell process, its retained output, and the clients viewing it (ADR-0071). +//! +//! Three threads serve each shell: a reader that copies PTY output into the replay and to every +//! viewer, a writer that feeds input so a shell that stops reading never blocks a socket, and a +//! monitor that reports status changes. None of them holds the protocol lock. + +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc::{self, Receiver, SyncSender, TrySendError}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use openaide_app_server_protocol::ids::ProjectId; +use openaide_app_server_protocol::terminals::{TerminalStatus, TerminalSummary}; +use portable_pty::{native_pty_system, CommandBuilder, MasterPty}; + +use super::replay::Replay; +use crate::pty_child::{pty_size, PtyChild}; + +/// Output chunks a viewer may fall behind by before it is dropped and must reattach. Dropping +/// keeps one slow connection from stalling the shell or the other viewers. +const VIEWER_BACKLOG_CHUNKS: usize = 256; +const READ_CHUNK_BYTES: usize = 16 * 1024; +/// Status is polled because no portable notification exists for a foreground process change. +const MONITOR_INTERVAL: Duration = Duration::from_millis(500); +pub(crate) const MAX_COLS: u16 = 500; +pub(crate) const MAX_ROWS: u16 = 200; + +pub(crate) fn valid_size(cols: u16, rows: u16) -> bool { + (2..=MAX_COLS).contains(&cols) && (2..=MAX_ROWS).contains(&rows) +} + +/// What a newly attached viewer receives: the replay, then live output until the channel closes. +pub(crate) struct ViewerAttach { + pub(crate) replay: Vec, + pub(crate) output: Receiver>, +} + +struct Viewer { + id: u64, + output: SyncSender>, +} + +#[derive(Clone, PartialEq, Eq)] +struct Status { + status: TerminalStatus, + command: Option, + folder: String, + exit_code: Option, +} + +struct Output { + replay: Replay, + viewers: Vec, + next_viewer_id: u64, + /// No further output will arrive; attaches receive the replay and end. + ended: bool, +} + +pub(crate) struct Shell { + pub(crate) terminal_id: String, + pub(crate) project_id: ProjectId, + opened_at: u64, + status: Mutex, + output: Mutex, + master: Mutex>, + /// Taken when the Terminal closes, which kills the process group. + child: Mutex>, + input: SyncSender>, + closed: AtomicBool, +} + +impl Shell { + /// Starts the user's login shell in `folder`. `changed` runs on the monitor thread after the + /// published status changes. + pub(crate) fn spawn( + terminal_id: String, + project_id: ProjectId, + folder: &Path, + cols: u16, + rows: u16, + opened_at: u64, + changed: impl Fn(&Shell) + Send + 'static, + ) -> std::io::Result> { + let pair = native_pty_system() + .openpty(pty_size(cols, rows)) + .map_err(std::io::Error::other)?; + let child = PtyChild::new( + pair.slave + .spawn_command(login_shell(folder)) + .map_err(std::io::Error::other)?, + ); + // The parent's copy of the slave would keep the PTY open after the shell exits. + drop(pair.slave); + let reader = pair + .master + .try_clone_reader() + .map_err(std::io::Error::other)?; + let mut writer = pair.master.take_writer().map_err(std::io::Error::other)?; + let (input, input_receive) = mpsc::sync_channel::>(64); + std::thread::spawn(move || { + while let Ok(bytes) = input_receive.recv() { + if writer + .write_all(&bytes) + .and_then(|_| writer.flush()) + .is_err() + { + break; + } + } + }); + let shell = Arc::new(Self { + terminal_id, + project_id, + opened_at, + status: Mutex::new(Status { + status: TerminalStatus::Idle, + command: None, + folder: folder.display().to_string(), + exit_code: None, + }), + output: Mutex::new(Output { + replay: Replay::default(), + viewers: Vec::new(), + next_viewer_id: 0, + ended: false, + }), + master: Mutex::new(pair.master), + child: Mutex::new(Some(child)), + input, + closed: AtomicBool::new(false), + }); + let reading = shell.clone(); + std::thread::spawn(move || reading.read_output(reader)); + let monitored = shell.clone(); + std::thread::spawn(move || monitored.monitor(changed)); + Ok(shell) + } + + pub(crate) fn summary(&self) -> TerminalSummary { + let status = self + .status + .lock() + .expect("terminal status poisoned") + .clone(); + TerminalSummary { + terminal_id: self.terminal_id.clone(), + project_id: self.project_id.clone(), + folder: status.folder, + command: status.command, + status: status.status, + exit_code: status.exit_code, + opened_at: self.opened_at, + } + } + + pub(crate) fn is_busy(&self) -> bool { + self.status.lock().expect("terminal status poisoned").status == TerminalStatus::Busy + } + + pub(crate) fn viewer_count(&self) -> usize { + self.output + .lock() + .expect("terminal output poisoned") + .viewers + .len() + } + + /// Registers a viewer under the output lock, so the replay and live output neither overlap + /// nor leave a gap. + pub(crate) fn attach(&self) -> (u64, ViewerAttach) { + let mut output = self.output.lock().expect("terminal output poisoned"); + let (sender, receiver) = mpsc::sync_channel(VIEWER_BACKLOG_CHUNKS); + let id = output.next_viewer_id; + output.next_viewer_id += 1; + if !output.ended { + output.viewers.push(Viewer { id, output: sender }); + } + ( + id, + ViewerAttach { + replay: output.replay.contents(), + output: receiver, + }, + ) + } + + /// Tells a viewer whose channel closed whether the shell ended or the viewer fell behind. + pub(crate) fn output_ended(&self) -> bool { + self.output.lock().expect("terminal output poisoned").ended + } + + pub(crate) fn detach(&self, viewer_id: u64) { + self.output + .lock() + .expect("terminal output poisoned") + .viewers + .retain(|viewer| viewer.id != viewer_id); + } + + /// Blocks only the calling socket when the shell stops reading input. + pub(crate) fn write_input(&self, bytes: Vec) -> bool { + !self.closed.load(Ordering::Acquire) && self.input.send(bytes).is_ok() + } + + pub(crate) fn resize(&self, cols: u16, rows: u16) -> bool { + self.master + .lock() + .expect("terminal master poisoned") + .resize(pty_size(cols, rows)) + .is_ok() + } + + /// Ends the shell and its process group. Viewer channels close, which ends their sockets. + pub(crate) fn terminate(&self) { + if self.closed.swap(true, Ordering::AcqRel) { + return; + } + // Dropping a running child kills its group and reaps it. + drop(self.child.lock().expect("terminal child poisoned").take()); + let mut output = self.output.lock().expect("terminal output poisoned"); + output.ended = true; + output.viewers.clear(); + } + + fn read_output(&self, mut reader: Box) { + let mut bytes = vec![0_u8; READ_CHUNK_BYTES]; + loop { + // EOF or EIO once the shell and everything holding the PTY have exited. + let count = match reader.read(&mut bytes) { + Ok(0) | Err(_) => break, + Ok(count) => count, + }; + let chunk = &bytes[..count]; + let mut output = self.output.lock().expect("terminal output poisoned"); + output.replay.push(chunk); + let before = output.viewers.len(); + output + .viewers + .retain(|viewer| match viewer.output.try_send(chunk.to_vec()) { + Ok(()) => true, + Err(TrySendError::Full(_)) | Err(TrySendError::Disconnected(_)) => false, + }); + let dropped = before - output.viewers.len(); + drop(output); + if dropped > 0 { + crate::logging::info( + "terminal_viewer_dropped", + serde_json::json!({ + "terminal_id": self.terminal_id, + "viewers_dropped": dropped, + "reason_code": "viewer_behind", + }), + ); + } + } + let mut output = self.output.lock().expect("terminal output poisoned"); + output.ended = true; + output.viewers.clear(); + } + + fn monitor(&self, changed: impl Fn(&Shell)) { + while !self.closed.load(Ordering::Acquire) { + std::thread::sleep(MONITOR_INTERVAL); + if self.closed.load(Ordering::Acquire) { + return; + } + let exit = self + .child + .lock() + .expect("terminal child poisoned") + .as_mut() + .and_then(|child| child.try_exit().ok().flatten()); + let next = match exit.as_ref() { + Some(exit) => Status { + status: TerminalStatus::Exited, + command: None, + exit_code: Some(i32::try_from(exit.exit_code()).unwrap_or(i32::MAX)), + folder: self + .status + .lock() + .expect("terminal status poisoned") + .folder + .clone(), + }, + None => self.observe(), + }; + let mut status = self.status.lock().expect("terminal status poisoned"); + if *status == next { + continue; + } + *status = next; + drop(status); + changed(self); + if exit.is_some() { + return; + } + } + } + + /// Reads the foreground command and the shell's folder where the platform exposes them. + fn observe(&self) -> Status { + let shell_pid = self + .child + .lock() + .expect("terminal child poisoned") + .as_ref() + .and_then(PtyChild::process_id); + let previous = self + .status + .lock() + .expect("terminal status poisoned") + .clone(); + #[cfg(unix)] + { + let foreground = self + .master + .lock() + .expect("terminal master poisoned") + .process_group_leader() + .and_then(|pid| u32::try_from(pid).ok()); + let busy = matches!((foreground, shell_pid), (Some(fg), Some(shell)) if fg != shell); + Status { + status: if busy { + TerminalStatus::Busy + } else { + TerminalStatus::Idle + }, + command: foreground.filter(|_| busy).and_then(platform::command_name), + folder: shell_pid + .and_then(platform::current_folder) + .map(|folder| folder.display().to_string()) + .unwrap_or(previous.folder), + exit_code: None, + } + } + // A ConPTY has no foreground process group, and App Server is not attached to the + // pseudo console, so a program the shell started is the sign of a running command. + // PowerShell runs its own cmdlets in-process, so those count as idle. + #[cfg(windows)] + { + let command = shell_pid.and_then(platform::running_child); + Status { + status: if command.is_some() { + TerminalStatus::Busy + } else { + TerminalStatus::Idle + }, + command, + // TODO(terminals): follow the shell's folder on Windows; PowerShell's location + // is not its process's current directory, so it needs the shell's cooperation. + folder: previous.folder, + exit_code: None, + } + } + #[cfg(not(any(unix, windows)))] + { + let _ = shell_pid; + previous + } + } +} + +/// The user's login shell, run in the App Server's environment (ADR-0071). +fn login_shell(folder: &Path) -> CommandBuilder { + #[cfg(unix)] + let mut command = { + let shell = std::env::var_os("SHELL") + .filter(|shell| !shell.is_empty()) + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("/bin/sh")); + let mut command = CommandBuilder::new(shell); + command.arg("-l"); + command + }; + #[cfg(not(unix))] + let mut command = { + let mut command = CommandBuilder::new("powershell.exe"); + command.arg("-NoLogo"); + command + }; + command.cwd(folder); + command.env("TERM", "xterm-256color"); + command.env("COLORTERM", "truecolor"); + command +} + +#[cfg(unix)] +mod platform { + use std::path::PathBuf; + + #[cfg(target_os = "linux")] + pub(super) fn command_name(pid: u32) -> Option { + let name = std::fs::read_to_string(format!("/proc/{pid}/comm")).ok()?; + Some(name.trim_end().to_string()).filter(|name| !name.is_empty()) + } + + #[cfg(target_os = "linux")] + pub(super) fn current_folder(pid: u32) -> Option { + std::fs::read_link(format!("/proc/{pid}/cwd")).ok() + } + + // TODO(terminals): read the foreground command and folder on macOS through libproc; until + // then a busy Terminal shows its folder as the title and the start folder stays displayed. + #[cfg(not(target_os = "linux"))] + pub(super) fn command_name(_pid: u32) -> Option { + None + } + + #[cfg(not(target_os = "linux"))] + pub(super) fn current_folder(_pid: u32) -> Option { + None + } +} + +#[cfg(windows)] +mod platform { + use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE}; + use windows_sys::Win32::System::Diagnostics::ToolHelp::{ + CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W, + TH32CS_SNAPPROCESS, + }; + + /// The program name of the shell's most recently listed child process, if it has one. + pub(super) fn running_child(shell_pid: u32) -> Option { + // SAFETY: the snapshot handle is checked and closed here, and each entry is a + // correctly sized PROCESSENTRY32W that the API fills in. + unsafe { + let snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); + if snapshot == INVALID_HANDLE_VALUE { + return None; + } + let mut entry: PROCESSENTRY32W = std::mem::zeroed(); + entry.dwSize = std::mem::size_of::() as u32; + let mut child = None; + let mut more = Process32FirstW(snapshot, &mut entry) != 0; + while more { + if entry.th32ParentProcessID == shell_pid && entry.th32ProcessID != shell_pid { + let name = &entry.szExeFile; + let length = name + .iter() + .position(|unit| *unit == 0) + .unwrap_or(name.len()); + child = Some(String::from_utf16_lossy(&name[..length])); + } + more = Process32NextW(snapshot, &mut entry) != 0; + } + CloseHandle(snapshot); + child.map(|name| program_name(&name)) + } + } + + fn program_name(executable: &str) -> String { + let length = executable.len(); + if length > 4 && executable[length - 4..].eq_ignore_ascii_case(".exe") { + executable[..length - 4].to_string() + } else { + executable.to_string() + } + } +} diff --git a/openaide-rs/app-server/src/terminals_tests.rs b/openaide-rs/app-server/src/terminals_tests.rs new file mode 100644 index 00000000..84e7e2a8 --- /dev/null +++ b/openaide-rs/app-server/src/terminals_tests.rs @@ -0,0 +1,108 @@ +use crate::test_sync::wait_until; +use std::path::Path; + +use openaide_app_server_protocol::errors::ProtocolErrorCode; +use openaide_app_server_protocol::ids::ProjectId; +use openaide_app_server_protocol::terminals::TerminalStatus; + +use super::Terminals; + +fn open(terminals: &Terminals) -> (String, String) { + let opened = terminals + .open( + ProjectId::from("project".to_string()), + Path::new("/"), + 80, + 24, + "project", + ) + .expect("shell starts"); + (opened.terminal.terminal_id, opened.attach.token) +} + +#[test] +fn attach_token_is_single_use() { + let terminals = Terminals::default(); + let (terminal_id, token) = open(&terminals); + + assert!(terminals.claim(&token).is_some()); + assert!(terminals.claim(&token).is_none()); + let renewed = terminals.attach(&terminal_id).unwrap(); + assert!(terminals.claim(&renewed.token).is_some()); + terminals.close_all(); +} + +#[test] +fn viewer_receives_output_and_a_later_viewer_receives_it_as_replay() { + let terminals = Terminals::default(); + let (_, token) = open(&terminals); + let shell = terminals.claim(&token).unwrap(); + let (_, first) = shell.attach(); + + assert!(shell.write_input(b"echo openaide-$((40+2))\r".to_vec())); + let mut seen = Vec::new(); + wait_until("the output arrived", || { + while let Ok(chunk) = first.output.try_recv() { + seen.extend(chunk); + } + String::from_utf8_lossy(&seen).contains("openaide-42") + }); + let (_, later) = shell.attach(); + assert!(String::from_utf8_lossy(&later.replay).contains("openaide-42")); + terminals.close_all(); +} + +#[test] +fn closing_a_busy_terminal_needs_confirmation() { + let terminals = Terminals::default(); + let (terminal_id, token) = open(&terminals); + let shell = terminals.claim(&token).unwrap(); + assert!(shell.write_input(b"sleep 30\r".to_vec())); + wait_until("the shell runs a command", || shell.is_busy()); + + let refused = terminals.close(&terminal_id, false).unwrap_err(); + assert_eq!(refused.code, ProtocolErrorCode::Conflict); + terminals.close(&terminal_id, true).unwrap(); + assert!(terminals.snapshot().terminals.is_empty()); + assert_eq!( + terminals.close(&terminal_id, true).unwrap_err().code, + ProtocolErrorCode::NotFound + ); +} + +#[test] +fn an_exited_shell_stays_listed_and_reports_changes() { + let terminals = Terminals::default(); + let (changed_send, changed) = std::sync::mpsc::channel(); + terminals.on_changed(move || { + let _ = changed_send.send(()); + }); + let (terminal_id, token) = open(&terminals); + let shell = terminals.claim(&token).unwrap(); + assert!(shell.write_input(b"exit 3\r".to_vec())); + + wait_until("the shell exited", || { + terminals.snapshot().terminals[0].status == TerminalStatus::Exited + }); + let summary = &terminals.snapshot().terminals[0]; + assert_eq!(summary.exit_code, Some(3)); + assert!(changed.try_recv().is_ok()); + assert_eq!(terminals.open_count(), 0); + terminals.close(&terminal_id, false).unwrap(); +} + +#[test] +fn rejects_an_out_of_range_size() { + let terminals = Terminals::default(); + let refused = terminals + .open( + ProjectId::from("project".to_string()), + Path::new("/"), + 1, + 24, + "project", + ) + .err() + .unwrap(); + assert_eq!(refused.code, ProtocolErrorCode::ValidationFailed); +} diff --git a/openaide-rs/app-server/tests/keeper_command.rs b/openaide-rs/app-server/tests/keeper_command.rs new file mode 100644 index 00000000..26d21bbe --- /dev/null +++ b/openaide-rs/app-server/tests/keeper_command.rs @@ -0,0 +1,151 @@ +//! Drives the built App Server program the way an App Shell's SSH launcher does +//! on an SSH Computer (ADR-0069). + +#![cfg(unix)] + +use std::io::{Read, Write}; +use std::net::{SocketAddr, TcpStream}; +use std::path::Path; +use std::process::{Command, Stdio}; + +use serde_json::{json, Value}; + +const PROGRAM: &str = env!("CARGO_BIN_EXE_openaide-app-server"); + +fn keep(root: &Path, arguments: &[&str]) -> (bool, Value) { + let output = Command::new(PROGRAM) + .arg("keep") + .args(arguments) + .env("OPENAIDE_STORAGE_ROOT", root.join("state")) + .env("OPENAIDE_RUNTIME_ROOT", root.join("run")) + .env_remove("OPENAIDE_APP_SERVER_PROTOCOL") + .env_remove("OPENAIDE_RUNTIME_PROTOCOL") + .stdin(Stdio::null()) + .stderr(Stdio::null()) + .output() + .unwrap(); + let text = String::from_utf8_lossy(&output.stdout); + let report = serde_json::from_str(text.trim()) + .unwrap_or_else(|_| panic!("keep {arguments:?} printed {text:?}")); + (output.status.success(), report) +} + +/// Stops a keeper a failed assertion left behind. +struct StopKeeper<'a>(&'a Path); + +impl Drop for StopKeeper<'_> { + fn drop(&mut self) { + let _ = keep(self.0, &["stop"]); + } +} + +/// A minimal App Shell: one HTTP request per call, as the launcher's client is. +struct Shell { + address: SocketAddr, + path: String, + token: String, + next: u32, +} + +impl Shell { + fn attach(report: &Value) -> Self { + let url = report["endpointUrl"].as_str().unwrap(); + let rest = url.strip_prefix("http://").unwrap(); + let (authority, path) = rest.split_at(rest.find('/').unwrap_or(rest.len())); + let mut shell = Self { + address: authority.parse().unwrap(), + path: if path.is_empty() { "/" } else { path }.to_string(), + token: report["authToken"].as_str().unwrap().to_string(), + next: 0, + }; + shell.call( + "client/initialize", + json!({ + "clientInstanceId": "desktop-1", + "shell": { "kind": "desktop", "name": "Desktop", "version": "test" }, + "requestedSurface": { "kind": "home" }, + }), + ); + shell + } + + fn call(&mut self, method: &str, params: Value) -> Value { + self.next += 1; + let id = format!("shell-{}", self.next); + let body = + json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params }).to_string(); + let mut stream = TcpStream::connect(self.address).unwrap(); + write!( + stream, + "POST {} HTTP/1.1\r\nHost: {}\r\nAuthorization: Bearer {}\r\nX-OpenAIDE-Connection-Id: desktop-1\r\nX-OpenAIDE-Client-Instance-Id: desktop-1\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + self.path, self.address, self.token, body.len(), body + ) + .unwrap(); + let mut response = String::new(); + stream.read_to_string(&mut response).unwrap(); + let (_, body) = response.split_once("\r\n\r\n").unwrap(); + let messages = match serde_json::from_str::(body).unwrap() { + Value::Array(messages) => messages, + message => vec![message], + }; + let reply = messages + .into_iter() + .find(|message| message["id"] == id) + .unwrap(); + assert!(reply.get("error").is_none(), "{method} failed: {reply}"); + reply["result"]["result"].clone() + } +} + +#[test] +fn start_brings_up_one_kept_app_server_that_stop_lets_go() { + let root = tempfile::tempdir().unwrap(); + let _cleanup = StopKeeper(root.path()); + + let (ok, first) = keep(root.path(), &["start"]); + assert!(ok, "{first}"); + assert_eq!(first["outcome"], "started", "{first}"); + assert_eq!(first["keeper"], "new"); + assert_eq!(first["appVersion"], env!("CARGO_PKG_VERSION")); + + let (ok, second) = keep(root.path(), &["start"]); + assert!(ok, "{second}"); + assert_eq!(second["outcome"], "attached", "{second}"); + assert_eq!(second["keeper"], "existing"); + assert_eq!(second["endpointUrl"], first["endpointUrl"]); + + let mut shell = Shell::attach(&second); + let impact = shell.call("client/replacementImpact", json!({})); + let kinds: Vec<&str> = impact["otherClients"] + .as_array() + .unwrap() + .iter() + .map(|client| client["kind"].as_str().unwrap()) + .collect(); + assert_eq!(kinds, ["keeper"], "{impact}"); + + let (ok, stopped) = keep(root.path(), &["stop"]); + assert!(ok, "{stopped}"); + assert_eq!(stopped["outcome"], "stopped"); + let impact = shell.call("client/replacementImpact", json!({})); + assert!(impact.get("otherClients").is_none(), "{impact}"); + + let (ok, again) = keep(root.path(), &["stop"]); + assert!(ok, "{again}"); + assert_eq!(again["outcome"], "not_running"); + shell.call("client/detach", json!({})); +} + +#[test] +fn join_without_a_running_app_server_starts_nothing() { + let root = tempfile::tempdir().unwrap(); + let _cleanup = StopKeeper(root.path()); + + let (ok, report) = keep(root.path(), &["start", "--join"]); + + assert!(ok, "{report}"); + assert_eq!( + report, + json!({ "outcome": "failed", "error": "not_running" }) + ); +} diff --git a/openaide-rs/app-server/tests/ssh_launch.rs b/openaide-rs/app-server/tests/ssh_launch.rs new file mode 100644 index 00000000..da57b953 --- /dev/null +++ b/openaide-rs/app-server/tests/ssh_launch.rs @@ -0,0 +1,126 @@ +//! Drives the shared SSH launcher against this machine: every command runs +//! through `sh` the way an SSH server runs it, and the forward is the identity, +//! so the release binary built here is installed, kept and stopped for real. + +#![cfg(target_os = "linux")] + +use std::io::Read; +use std::net::SocketAddr; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::sync::Arc; + +use openaide_remote_client::{ + LaunchError, Launched, RemoteOutput, ServerRelease, SshLauncher, SshTransport, StartMode, + StopOutcome, TransportError, +}; + +const PROGRAM: &str = env!("CARGO_BIN_EXE_openaide-app-server"); +const VERSION: &str = env!("CARGO_PKG_VERSION"); + +/// Runs commands in a temporary home with its own state and runtime roots. +struct LocalShell { + home: PathBuf, +} + +impl SshTransport for LocalShell { + type Forward = (); + + fn run( + &mut self, + command: &str, + input: Option<&mut dyn Read>, + ) -> Result { + let mut child = Command::new("sh") + .arg("-c") + .arg(command) + .env("HOME", &self.home) + .env("OPENAIDE_STORAGE_ROOT", self.home.join("state")) + .env("OPENAIDE_RUNTIME_ROOT", self.home.join("run")) + .env_remove("OPENAIDE_APP_SERVER_PROTOCOL") + .env_remove("OPENAIDE_RUNTIME_PROTOCOL") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .map_err(|_| TransportError { class: "spawn" })?; + let mut stdin = child.stdin.take().unwrap(); + if let Some(input) = input { + std::io::copy(input, &mut stdin).map_err(|_| TransportError { class: "upload" })?; + } + drop(stdin); + let output = child + .wait_with_output() + .map_err(|_| TransportError { class: "wait" })?; + Ok(RemoteOutput { + success: output.status.success(), + stdout: output.stdout, + }) + } + + fn forward(&mut self, remote_port: u16) -> Result<((), SocketAddr), TransportError> { + Ok(((), SocketAddr::from(([127, 0, 0, 1], remote_port)))) + } +} + +/// Lets a kept App Server go if an assertion fails first. +struct StopKeeper<'a>(&'a SshLauncher, &'a Path); + +impl Drop for StopKeeper<'_> { + fn drop(&mut self) { + let mut shell = LocalShell { + home: self.1.to_path_buf(), + }; + let _ = self.0.stop(&mut shell, "cleanup"); + } +} + +fn sha256(path: &str) -> String { + let output = Command::new("sha256sum").arg(path).output().unwrap(); + String::from_utf8(output.stdout).unwrap()[..64].to_string() +} + +fn launch(launcher: &SshLauncher, home: &Path) -> Result, LaunchError> { + let mut shell = LocalShell { + home: home.to_path_buf(), + }; + let mut fetch = || -> Result, &'static str> { + Ok(Box::new(std::fs::File::open(PROGRAM).unwrap())) + }; + launcher.launch(&mut shell, StartMode::Keep, &mut fetch, "test") +} + +#[test] +fn installs_keeps_and_stops_the_release_through_a_shell() { + let home = tempfile::tempdir().unwrap(); + let launcher = SshLauncher::new( + ServerRelease::new(VERSION, &sha256(PROGRAM)).unwrap(), + Arc::new(|_, _| {}), + Box::new(|_| {}), + ); + let _cleanup = StopKeeper(&launcher, home.path()); + + let Ok(Launched::Ready(first)) = launch(&launcher, home.path()) else { + panic!("the first launch did not reach a ready App Server"); + }; + assert!(!first.keeper_existed); + assert_eq!(first.app_version, VERSION); + assert!(home + .path() + .join(format!( + ".local/share/openaide/app-server/{VERSION}/openaide-app-server" + )) + .is_file()); + + let Ok(Launched::Ready(second)) = launch(&launcher, home.path()) else { + panic!("the second launch did not reach a ready App Server"); + }; + assert!(second.keeper_existed); + assert_eq!(second.endpoint_url, first.endpoint_url); + assert_eq!(second.auth_token, first.auth_token); + + let mut shell = LocalShell { + home: home.path().to_path_buf(), + }; + assert_eq!(launcher.stop(&mut shell, "test"), Ok(StopOutcome::Stopped)); +} diff --git a/openaide-rs/remote-client/Cargo.toml b/openaide-rs/remote-client/Cargo.toml index ab6ce5bb..bde84d7a 100644 --- a/openaide-rs/remote-client/Cargo.toml +++ b/openaide-rs/remote-client/Cargo.toml @@ -5,11 +5,24 @@ edition.workspace = true license.workspace = true publish.workspace = true +[features] +default = ["pairing"] +# Remote Devices over iroh (ADR-0062). +pairing = ["dep:data-encoding", "dep:iroh", "dep:tokio"] +# Unpacking the published Linux App Server archive for SSH Computers. +release-archive = ["dep:flate2", "dep:sha2", "dep:tar"] + [dependencies] -data-encoding.workspace = true -iroh.workspace = true +data-encoding = { workspace = true, optional = true } +flate2 = { version = "1", optional = true } +iroh = { workspace = true, optional = true } openaide-app-server-protocol.workspace = true serde.workspace = true serde_json.workspace = true +sha2 = { version = "0.10", optional = true } +tar = { version = "0.4", optional = true } thiserror.workspace = true -tokio.workspace = true +tokio = { workspace = true, optional = true } + +[dev-dependencies] +tempfile.workspace = true diff --git a/openaide-rs/remote-client/src/client.rs b/openaide-rs/remote-client/src/client.rs index e426254c..ce6b435a 100644 --- a/openaide-rs/remote-client/src/client.rs +++ b/openaide-rs/remote-client/src/client.rs @@ -1,7 +1,6 @@ //! This device's endpoint and its one connection to a paired App Server. use std::sync::atomic::{AtomicU32, Ordering}; -use std::sync::Arc; use std::time::{Duration, Instant}; use iroh::endpoint::{ @@ -26,8 +25,7 @@ const CLOSE_UNTRUSTED: u32 = 1; const CLOSE_REMOVED: u32 = 2; const CLOSE_UNKNOWN_PROTOCOL: u32 = 3; -/// Receives metadata-only lifecycle events: a stable name and safe fields. -pub type Logger = Arc; +pub use crate::Logger; /// Why the paired computer cannot be used right now. #[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] diff --git a/openaide-rs/remote-client/src/gateway.rs b/openaide-rs/remote-client/src/gateway.rs index 198b960d..deec5b78 100644 --- a/openaide-rs/remote-client/src/gateway.rs +++ b/openaide-rs/remote-client/src/gateway.rs @@ -184,13 +184,16 @@ async fn serve(mut socket: TcpStream, shared: Arc) { /// Checks the Frontend's token and returns the request head without it. /// /// A preflight carries no credentials by design; the App Server answers it and -/// nothing else is reachable without the token. +/// nothing else is reachable without the token. A Terminal socket (ADR-0071) is +/// the other exception: a browser WebSocket cannot send headers, and the App +/// Server admits it only with a single-use attach token issued over an +/// authorized request, so the gateway token would add nothing there. fn forwarded_head(head: &[u8], token: &str) -> Result, u16> { let head = std::str::from_utf8(head).map_err(|_| 400u16)?; let mut lines = head.split("\r\n"); let request_line = lines.next().filter(|line| !line.is_empty()).ok_or(400u16)?; let preflight = request_line.starts_with("OPTIONS "); - let mut authorized = preflight; + let mut authorized = preflight || terminal_socket(request_line, head); let mut forwarded = Vec::with_capacity(head.len() + 4); forwarded.extend_from_slice(request_line.as_bytes()); forwarded.extend_from_slice(b"\r\n"); @@ -217,6 +220,20 @@ fn forwarded_head(head: &[u8], token: &str) -> Result, u16> { Ok(forwarded) } +/// Exactly `GET /terminal` asking for an upgrade; the App Server checks the rest. +fn terminal_socket(request_line: &str, head: &str) -> bool { + let target = request_line + .strip_prefix("GET ") + .and_then(|rest| rest.strip_suffix(" HTTP/1.1")); + target.is_some_and(|target| target.split('?').next() == Some("/terminal")) + && head.split("\r\n").skip(1).any(|line| { + line.split_once(':').is_some_and(|(name, value)| { + name.eq_ignore_ascii_case("upgrade") + && value.trim().eq_ignore_ascii_case("websocket") + }) + }) +} + async fn read_head(socket: &mut TcpStream) -> Option<(Vec, Vec)> { let mut buffer = Vec::with_capacity(1024); let mut chunk = [0u8; 1024]; diff --git a/openaide-rs/remote-client/src/gateway_tests.rs b/openaide-rs/remote-client/src/gateway_tests.rs index 5be4706b..fe8465d5 100644 --- a/openaide-rs/remote-client/src/gateway_tests.rs +++ b/openaide-rs/remote-client/src/gateway_tests.rs @@ -29,3 +29,25 @@ fn a_preflight_passes_without_credentials() { let head = b"OPTIONS /probe HTTP/1.1\r\nOrigin: tauri://localhost"; assert!(forwarded_head(head, "secret").is_ok()); } + +#[test] +fn a_terminal_socket_passes_on_its_attach_token_alone() { + let head = b"GET /terminal HTTP/1.1\r\nUpgrade: websocket\r\nConnection: Upgrade"; + assert!(forwarded_head(head, "secret").is_ok()); + // Only the upgrade on that exact path; every other route still needs the token. + assert_eq!( + forwarded_head(b"GET /terminal HTTP/1.1\r\nX-A: b", "secret"), + Err(401) + ); + assert_eq!( + forwarded_head( + b"GET /probe/terminal HTTP/1.1\r\nUpgrade: websocket", + "secret" + ), + Err(401) + ); + assert_eq!( + forwarded_head(b"GET /probe HTTP/1.1\r\nUpgrade: websocket", "secret"), + Err(401) + ); +} diff --git a/openaide-rs/remote-client/src/lib.rs b/openaide-rs/remote-client/src/lib.rs index 75a75245..049f01f7 100644 --- a/openaide-rs/remote-client/src/lib.rs +++ b/openaide-rs/remote-client/src/lib.rs @@ -5,15 +5,38 @@ //! [`Gateway`] listens on loopback and carries each request over one stream of //! the connection [`RemoteClient`] keeps open. The App Server trusts the //! connection's key, so no credential of the App Server ever reaches this side. +//! +//! SSH Computers (ADR-0069) need only the [`SshLauncher`]; the `pairing` +//! feature, on by default, adds the iroh client, so a shell that only launches +//! over SSH does not link iroh. +#[cfg(feature = "pairing")] mod client; +#[cfg(feature = "pairing")] mod gateway; +#[cfg(feature = "pairing")] mod pairing_code; +mod ssh; + +use std::sync::Arc; +/// Receives metadata-only lifecycle events: a stable name and safe fields. +pub type Logger = Arc; + +#[cfg(feature = "pairing")] pub use client::{ application_alpn, bind, device_key_from_text, device_key_text, new_device_key, redeem, Failure, - Logger, PairedServer, RemoteClient, + PairedServer, RemoteClient, }; +#[cfg(feature = "pairing")] pub use gateway::Gateway; +#[cfg(feature = "pairing")] pub use iroh::{Endpoint, PublicKey, SecretKey}; +#[cfg(feature = "pairing")] pub use pairing_code::{clamp_label, InviteCode, PairingCodeError}; +#[cfg(feature = "release-archive")] +pub use ssh::archive; +pub use ssh::{ + FetchBinary, LaunchError, Launched, Ready, RemoteOutput, ServerRelease, SshLauncher, + SshTransport, Stage, StartMode, StopOutcome, TransportError, +}; diff --git a/openaide-rs/remote-client/src/ssh.rs b/openaide-rs/remote-client/src/ssh.rs new file mode 100644 index 00000000..35881dfc --- /dev/null +++ b/openaide-rs/remote-client/src/ssh.rs @@ -0,0 +1,470 @@ +//! Starting the App Server on an SSH Computer (ADR-0069), shared by the App +//! Shells that offer one. +//! +//! The App Shell installs its own release of the App Server on the computer, +//! asks the installed binary's keeper to attach to or start the App Server for +//! the default state root, and forwards the App Server's loopback endpoint to +//! its own loopback. The App Shell then holds the token like a same-machine App +//! Shell; nothing is paired. Each App Shell brings its own [`SshTransport`]: the +//! Desktop drives the system `ssh`, Android an embedded client. +//! +//! Every call blocks; App Shells run it off their UI thread. + +#[cfg(feature = "release-archive")] +pub mod archive; +mod remote; +mod version; + +use std::io::Read; +use std::net::SocketAddr; +use std::time::Instant; + +use openaide_app_server_protocol::client::APP_SERVER_PROTOCOL_VERSION; +use serde_json::json; + +use crate::Logger; +use remote::{KeeperReport, Probe, RemoteEndpoint}; +use version::Running; + +/// One SSH connection to the computer, as the App Shell implements it. +pub trait SshTransport { + /// Kept by the App Shell while it uses the App Server; dropping it ends the forward. + type Forward; + + /// Runs one command line on the computer, streaming `input` to its stdin. + /// A command that runs and fails is an `Ok` output; `Err` means SSH itself failed. + fn run( + &mut self, + command: &str, + input: Option<&mut dyn Read>, + ) -> Result; + + /// Forwards a port on this device's loopback to `remote_port` on the + /// computer's loopback and returns the local address. + fn forward(&mut self, remote_port: u16) -> Result<(Self::Forward, SocketAddr), TransportError>; +} + +pub struct RemoteOutput { + pub success: bool, + pub stdout: Vec, +} + +/// Why SSH failed, as a stable class the transport chooses, such as +/// `unreachable`, `auth_failed`, or `host_key_rejected`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +#[error("ssh failed: {class}")] +pub struct TransportError { + pub class: &'static str, +} + +/// The App Server release this App Shell ships: its version and the SHA-256 of +/// its Linux x64 binary. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ServerRelease { + version: String, + sha256: String, +} + +impl ServerRelease { + /// `None` unless the version is a plain release version and the checksum is + /// lowercase hex, because both become words of a remote command. + pub fn new(version: &str, sha256: &str) -> Option { + (remote::is_version(version) && remote::is_sha256(sha256)).then(|| Self { + version: version.to_string(), + sha256: sha256.to_string(), + }) + } + + pub fn version(&self) -> &str { + &self.version + } + + /// The SHA-256 of the Linux x64 binary, as lowercase hex. + pub fn sha256(&self) -> &str { + &self.sha256 + } +} + +/// Supplies the release binary only when the computer lacks it, so a first +/// download happens once and only when needed. Errors are stable classes. +pub type FetchBinary<'a> = &'a mut dyn FnMut() -> Result, &'static str>; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StartMode { + /// Attach to the App Server already there, or start one; never replace one. + Keep, + /// Replace an older App Server. Only after the user confirmed the update. + Replace, +} + +/// A step the startup screen names while it runs. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Stage { + Probe, + Fetch, + Upload, + Start, + Forward, +} + +impl Stage { + pub fn as_str(self) -> &'static str { + match self { + Self::Probe => "probe", + Self::Fetch => "fetch", + Self::Upload => "upload", + Self::Start => "start", + Self::Forward => "forward", + } + } +} + +pub enum Launched { + Ready(Ready), + /// An older App Server this App Shell cannot use holds the state root. + /// Launching again with [`StartMode::Replace`] updates it once the user + /// agrees to interrupt whatever it is doing. + UpdateRequired { + running_version: String, + }, +} + +/// An App Server this App Shell can use through the forward. Not `Debug`: it +/// holds the token. +pub struct Ready { + pub forward: F, + pub endpoint_url: String, + pub auth_token: String, + pub app_version: String, + /// The App Server is older than this App Shell but speaks its protocol, so + /// the App Shell attached and may offer an update. + pub update_available: bool, + /// Another App Shell's keeper already held the App Server. + pub keeper_existed: bool, + /// Logind will end the App Server when the user's last session closes; + /// `loginctl enable-linger` keeps it. + pub suggest_linger: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum LaunchError { + #[error("ssh failed during {}: {}", .stage.as_str(), .error.class)] + Transport { stage: Stage, error: TransportError }, + #[error("the computer is not Linux x64")] + UnsupportedPlatform, + #[error("the release binary is unavailable: {0}")] + Fetch(&'static str), + #[error("the release binary could not be installed: {0}")] + Install(&'static str), + #[error("the computer runs a newer App Server ({running_version})")] + ServerNewer { running_version: String }, + #[error("the computer runs an App Server of an unknown version")] + ServerVersionUnknown, + #[error("the keeper failed: {0}")] + Keeper(String), + #[error("the computer gave an unreadable answer during {}", .0.as_str())] + UnreadableAnswer(Stage), +} + +impl LaunchError { + pub fn stage(&self) -> Stage { + match self { + Self::Transport { stage, .. } | Self::UnreadableAnswer(stage) => *stage, + Self::UnsupportedPlatform => Stage::Probe, + Self::Fetch(_) => Stage::Fetch, + Self::Install(_) => Stage::Upload, + Self::ServerNewer { .. } | Self::ServerVersionUnknown | Self::Keeper(_) => Stage::Start, + } + } + + pub fn class(&self) -> &str { + match self { + Self::Transport { error, .. } => error.class, + Self::UnsupportedPlatform => "unsupported_platform", + Self::Fetch(class) | Self::Install(class) => class, + Self::ServerNewer { .. } => "server_newer", + Self::ServerVersionUnknown => "server_version_unknown", + Self::Keeper(class) => class, + Self::UnreadableAnswer(_) => "unreadable_answer", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StopOutcome { + Stopped, + NotRunning, + /// The keeper did not end in time; the App Server keeps running for now. + StillRunning, +} + +/// Starts App Servers on SSH Computers for one App Shell release. +pub struct SshLauncher { + release: ServerRelease, + logger: Logger, + progress: Box, +} + +impl SshLauncher { + pub fn new( + release: ServerRelease, + logger: Logger, + progress: Box, + ) -> Self { + Self { + release, + logger, + progress, + } + } + + /// Installs this release if the computer lacks it, has its keeper hold an + /// App Server, and forwards it. `operation_id` correlates the log events. + pub fn launch( + &self, + transport: &mut T, + mode: StartMode, + fetch: FetchBinary<'_>, + operation_id: &str, + ) -> Result, LaunchError> { + let started = Instant::now(); + (self.logger)( + "ssh_launch_started", + json!({ "operationId": operation_id, "mode": mode_name(mode) }), + ); + let result = self.launch_stages(transport, mode, fetch, operation_id); + let mut fields = json!({ + "operationId": operation_id, + "durationMs": started.elapsed().as_millis() as u64, + }); + match &result { + Ok(Launched::Ready(ready)) => { + fields["outcome"] = json!("ready"); + fields["keeper"] = json!(if ready.keeper_existed { + "existing" + } else { + "new" + }); + fields["updateAvailable"] = json!(ready.update_available); + fields["suggestLinger"] = json!(ready.suggest_linger); + } + Ok(Launched::UpdateRequired { .. }) => fields["outcome"] = json!("update_required"), + Err(error) => { + fields["outcome"] = json!("failure"); + fields["stage"] = json!(error.stage().as_str()); + fields["errorClass"] = json!(error.class()); + } + } + (self.logger)("ssh_launch_completed", fields); + result + } + + fn launch_stages( + &self, + transport: &mut T, + mode: StartMode, + fetch: FetchBinary<'_>, + operation_id: &str, + ) -> Result, LaunchError> { + let version = self.release.version.as_str(); + let probe = self.stage(Stage::Probe, operation_id, || { + let output = run(transport, Stage::Probe, &remote::probe(version), None)?; + let probe = Probe::parse(&output.stdout); + if !probe.is_supported() { + return Err(LaunchError::UnsupportedPlatform); + } + Ok(probe) + })?; + if probe.installed_sha256.as_deref() != Some(self.release.sha256.as_str()) { + let mut binary = self.stage(Stage::Fetch, operation_id, || { + fetch().map_err(LaunchError::Fetch) + })?; + self.stage(Stage::Upload, operation_id, || { + let command = remote::install(version, &self.release.sha256); + let output = run(transport, Stage::Upload, &command, Some(&mut *binary))?; + remote::install_verdict(&output.stdout).map_err(LaunchError::Install) + })?; + } + + let started = self.stage(Stage::Start, operation_id, || { + let flags: &[&str] = match mode { + StartMode::Keep => &["start"], + StartMode::Replace => &["start", "--replace"], + }; + let report = keep(transport, version, flags)?; + if report.outcome != "mismatch" { + return accept(report, false); + } + let running_version = report.app_version.clone().unwrap_or_default(); + match version::classify( + version, + APP_SERVER_PROTOCOL_VERSION, + &running_version, + report.protocol_version.as_deref(), + ) { + Running::Newer => Err(LaunchError::ServerNewer { running_version }), + Running::Unknown => Err(LaunchError::ServerVersionUnknown), + Running::OlderIncompatible => Ok(Start::UpdateRequired { running_version }), + Running::OlderCompatible => { + accept(keep(transport, version, &["start", "--join"])?, true) + } + } + })?; + let held = match started { + Start::Held(held) => held, + Start::UpdateRequired { running_version } => { + return Ok(Launched::UpdateRequired { running_version }) + } + }; + + let (forward, local) = self.stage(Stage::Forward, operation_id, || { + transport + .forward(held.endpoint.port) + .map_err(|error| LaunchError::Transport { + stage: Stage::Forward, + error, + }) + })?; + Ok(Launched::Ready(Ready { + forward, + endpoint_url: format!("http://{local}{}", held.endpoint.path), + auth_token: held.auth_token, + app_version: held.app_version, + update_available: held.joined_older, + keeper_existed: held.keeper_existed, + suggest_linger: probe.suggests_linger(), + })) + } + + /// Lets the App Server go: its keeper ends, and the App Server stops once + /// its last App Shell leaves too. + pub fn stop( + &self, + transport: &mut T, + operation_id: &str, + ) -> Result { + let started = Instant::now(); + let result = keep(transport, &self.release.version, &["stop"]).and_then(|report| { + match report.outcome.as_str() { + "stopped" => Ok(StopOutcome::Stopped), + "not_running" => Ok(StopOutcome::NotRunning), + "still_running" => Ok(StopOutcome::StillRunning), + _ => Err(LaunchError::Keeper(report.error_class())), + } + }); + (self.logger)( + "ssh_stop_completed", + json!({ + "operationId": operation_id, + "outcome": match &result { + Ok(StopOutcome::Stopped) => "stopped", + Ok(StopOutcome::NotRunning) => "not_running", + Ok(StopOutcome::StillRunning) => "still_running", + Err(_) => "failure", + }, + "errorClass": result.as_ref().err().map(LaunchError::class), + "durationMs": started.elapsed().as_millis() as u64, + }), + ); + result + } + + /// Announces a stage to the startup screen and logs how it ended. + fn stage( + &self, + stage: Stage, + operation_id: &str, + body: impl FnOnce() -> Result, + ) -> Result { + (self.progress)(stage); + (self.logger)( + "ssh_launch_stage_started", + json!({ "operationId": operation_id, "stage": stage.as_str() }), + ); + let started = Instant::now(); + let result = body(); + (self.logger)( + "ssh_launch_stage_completed", + json!({ + "operationId": operation_id, + "stage": stage.as_str(), + "outcome": if result.is_ok() { "success" } else { "failure" }, + "errorClass": result.as_ref().err().map(LaunchError::class), + "durationMs": started.elapsed().as_millis() as u64, + }), + ); + result + } +} + +enum Start { + Held(Held), + UpdateRequired { running_version: String }, +} + +/// The App Server a keeper holds, before it is forwarded. +struct Held { + endpoint: RemoteEndpoint, + auth_token: String, + app_version: String, + keeper_existed: bool, + joined_older: bool, +} + +fn accept(report: KeeperReport, joined_older: bool) -> Result { + if !matches!(report.outcome.as_str(), "started" | "attached" | "joined") { + return Err(LaunchError::Keeper(report.error_class())); + } + let unreadable = || LaunchError::UnreadableAnswer(Stage::Start); + let endpoint = report + .endpoint_url + .as_deref() + .and_then(RemoteEndpoint::parse) + .ok_or_else(unreadable)?; + Ok(Start::Held(Held { + endpoint, + auth_token: report + .auth_token + .filter(|token| !token.is_empty()) + .ok_or_else(unreadable)?, + app_version: report.app_version.unwrap_or_default(), + keeper_existed: report.keeper.as_deref() == Some("existing"), + joined_older, + })) +} + +fn keep( + transport: &mut T, + version: &str, + arguments: &[&str], +) -> Result { + let output = run( + transport, + Stage::Start, + &remote::keep(version, arguments), + None, + )?; + KeeperReport::parse(&output.stdout).ok_or(LaunchError::UnreadableAnswer(Stage::Start)) +} + +fn run( + transport: &mut T, + stage: Stage, + command: &str, + input: Option<&mut dyn Read>, +) -> Result { + transport + .run(command, input) + .map_err(|error| LaunchError::Transport { stage, error }) +} + +fn mode_name(mode: StartMode) -> &'static str { + match mode { + StartMode::Keep => "keep", + StartMode::Replace => "replace", + } +} + +#[cfg(test)] +#[path = "ssh_tests.rs"] +mod tests; diff --git a/openaide-rs/remote-client/src/ssh/archive.rs b/openaide-rs/remote-client/src/ssh/archive.rs new file mode 100644 index 00000000..0c386c0b --- /dev/null +++ b/openaide-rs/remote-client/src/ssh/archive.rs @@ -0,0 +1,90 @@ +//! The published Linux x64 App Server archive, as App Shells unpack it before +//! uploading the binary to an SSH Computer (ADR-0069). +//! +//! The archive holds one binary at [`ARCHIVE_ENTRY`]. Only a binary whose +//! SHA-256 is the one the App Shell was built with is kept; anything else is +//! deleted before it can be uploaded. + +use std::fs::File; +use std::io::{Read, Write}; +use std::path::Path; +use std::time::{SystemTime, UNIX_EPOCH}; + +use sha2::{Digest, Sha256}; + +/// The release asset name for `version`. +pub fn asset_name(version: &str) -> String { + format!("openaide-app-server-linux-x64-{version}.tar.gz") +} + +const ARCHIVE_ENTRY: &str = "openaide-app-server/openaide-app-server"; +const MAX_BINARY_BYTES: u64 = 512 * 1024 * 1024; + +/// Unpacks the binary to `destination` and keeps it only if its SHA-256 is +/// `sha256`. Errors are stable classes. +pub fn extract_verified( + archive: impl Read, + destination: &Path, + sha256: &str, +) -> Result<(), &'static str> { + let directory = destination.parent().ok_or("cache_unwritable")?; + std::fs::create_dir_all(directory).map_err(|_| "cache_unwritable")?; + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|elapsed| elapsed.as_nanos()) + .unwrap_or_default(); + let temporary = directory.join(format!(".download-{}-{nonce}", std::process::id())); + let result = (|| { + let mut entries = tar::Archive::new(flate2::read::GzDecoder::new(archive)); + for entry in entries.entries().map_err(|_| "archive_invalid")? { + let entry = entry.map_err(|_| "archive_invalid")?; + let is_binary = entry + .path() + .map(|path| path == Path::new(ARCHIVE_ENTRY)) + .unwrap_or(false); + if !is_binary { + continue; + } + let mut file = File::create(&temporary).map_err(|_| "cache_unwritable")?; + let digest = copy_hashing(entry.take(MAX_BINARY_BYTES), &mut file) + .map_err(|_| "archive_invalid")?; + file.sync_all().map_err(|_| "cache_unwritable")?; + if digest != sha256 { + return Err("checksum_mismatch"); + } + return std::fs::rename(&temporary, destination).map_err(|_| "cache_unwritable"); + } + Err("archive_invalid") + })(); + if result.is_err() { + let _ = std::fs::remove_file(&temporary); + } + result +} + +/// The SHA-256 of a file, as lowercase hex. +pub fn sha256_file(path: &Path) -> std::io::Result { + copy_hashing(File::open(path)?, &mut std::io::sink()) +} + +fn copy_hashing(mut source: impl Read, target: &mut impl Write) -> std::io::Result { + let mut hasher = Sha256::new(); + let mut buffer = vec![0; 64 * 1024]; + loop { + let read = source.read(&mut buffer)?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + target.write_all(&buffer[..read])?; + } + Ok(hasher + .finalize() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect()) +} + +#[cfg(test)] +#[path = "archive_tests.rs"] +mod tests; diff --git a/openaide-rs/remote-client/src/ssh/archive_tests.rs b/openaide-rs/remote-client/src/ssh/archive_tests.rs new file mode 100644 index 00000000..d489fa5a --- /dev/null +++ b/openaide-rs/remote-client/src/ssh/archive_tests.rs @@ -0,0 +1,66 @@ +use super::*; + +fn archive(entries: &[(&str, &[u8])]) -> Vec { + let mut builder = tar::Builder::new(flate2::write::GzEncoder::new( + Vec::new(), + flate2::Compression::fast(), + )); + for (path, bytes) in entries { + let mut header = tar::Header::new_gnu(); + header.set_size(bytes.len() as u64); + header.set_mode(0o755); + header.set_cksum(); + builder.append_data(&mut header, path, *bytes).unwrap(); + } + builder.into_inner().unwrap().finish().unwrap() +} + +fn sha256(bytes: &[u8]) -> String { + copy_hashing(bytes, &mut std::io::sink()).unwrap() +} + +#[test] +fn keeps_only_the_release_binary_from_the_archive() { + let directory = tempfile::tempdir().unwrap(); + let cache = directory.path().join("0.11.0/openaide-app-server"); + let packed = archive(&[ + ("openaide-app-server/README.md", b"guide"), + (ARCHIVE_ENTRY, b"binary"), + ]); + + assert_eq!( + extract_verified(packed.as_slice(), &cache, &sha256(b"other")), + Err("checksum_mismatch") + ); + assert!(!cache.exists()); + assert_eq!( + std::fs::read_dir(cache.parent().unwrap()).unwrap().count(), + 0 + ); + + extract_verified(packed.as_slice(), &cache, &sha256(b"binary")).unwrap(); + assert_eq!(std::fs::read(&cache).unwrap(), b"binary"); +} + +#[test] +fn an_archive_without_the_binary_is_refused() { + let directory = tempfile::tempdir().unwrap(); + let packed = archive(&[("elsewhere/openaide-app-server", b"binary")]); + + assert_eq!( + extract_verified( + packed.as_slice(), + &directory.path().join("x/openaide-app-server"), + &sha256(b"binary") + ), + Err("archive_invalid") + ); + assert_eq!( + extract_verified( + &b"not gzip"[..], + &directory.path().join("y/openaide-app-server"), + &sha256(b"") + ), + Err("archive_invalid") + ); +} diff --git a/openaide-rs/remote-client/src/ssh/remote.rs b/openaide-rs/remote-client/src/ssh/remote.rs new file mode 100644 index 00000000..c1c44b3a --- /dev/null +++ b/openaide-rs/remote-client/src/ssh/remote.rs @@ -0,0 +1,197 @@ +//! The commands an App Shell runs on an SSH Computer and the answers it reads. +//! +//! The SSH server hands each command line to the user's login shell, which may +//! be bash, zsh, fish or tcsh. Every command is therefore one `sh -c '…'` line: +//! the scripts hold no single quote, no `!` and no newline, which those shells +//! all pass to `sh` unchanged, and every argument is a single-quoted word. + +use serde::Deserialize; + +/// The versioned install directory, shared with the Desktop's WSL runtime. +const INSTALL_DIRECTORY: &str = r#"d="$HOME/.local/share/openaide/app-server/$1""#; + +/// Prints `key=value` facts: the platform, the installed binary's checksum, and +/// whether logind ends the user's processes at logout. +const PROBE: &str = r#"b="$d/openaide-app-server"; echo "platform=$(uname -sm)"; if [ -x "$b" ] && command -v sha256sum >/dev/null 2>&1; then echo "installed=$(sha256sum "$b" | cut -d " " -f 1)"; fi; if command -v loginctl >/dev/null 2>&1; then echo "kill_user_processes=$(loginctl show-session -p KillUserProcesses --value 2>/dev/null)"; echo "linger=$(loginctl show-user "$(id -un)" -p Linger --value 2>/dev/null)"; fi"#; + +/// Receives the binary on stdin and moves it into place only if its checksum +/// matches, so an interrupted or corrupted upload never becomes the install. +const INSTALL: &str = r#"command -v sha256sum >/dev/null 2>&1 || { echo checksum_tool_missing; exit 0; }; mkdir -p "$d" || { echo install_failed; exit 0; }; t="$d/openaide-app-server.upload.$$"; cat > "$t" || { rm -f "$t"; echo install_failed; exit 0; }; [ "$(sha256sum "$t" | cut -d " " -f 1)" = "$2" ] || { rm -f "$t"; echo checksum_mismatch; exit 0; }; chmod 700 "$t" && mv -f "$t" "$d/openaide-app-server" && echo installed || { rm -f "$t"; echo install_failed; }"#; + +/// Runs the installed binary's `keep` command with the remaining arguments. +const KEEP: &str = r#"b="$d/openaide-app-server"; shift; exec "$b" keep "$@""#; + +pub(crate) fn probe(version: &str) -> String { + command(PROBE, &[version]) +} + +pub(crate) fn install(version: &str, sha256: &str) -> String { + command(INSTALL, &[version, sha256]) +} + +pub(crate) fn keep(version: &str, arguments: &[&str]) -> String { + let mut all = vec![version]; + all.extend_from_slice(arguments); + command(KEEP, &all) +} + +fn command(script: &str, arguments: &[&str]) -> String { + let mut line = format!("sh -c '{INSTALL_DIRECTORY}; {script}' openaide"); + for argument in arguments { + line.push(' '); + line.push_str("e(argument)); + } + line +} + +fn quote(argument: &str) -> String { + format!("'{}'", argument.replace('\'', r"'\''")) +} + +/// What the probe found on the SSH Computer. +#[derive(Debug, Default, PartialEq, Eq)] +pub(crate) struct Probe { + pub(crate) platform: String, + pub(crate) installed_sha256: Option, + pub(crate) kill_user_processes: Option, + pub(crate) linger: Option, +} + +impl Probe { + pub(crate) fn parse(stdout: &[u8]) -> Self { + let mut probe = Self::default(); + for line in String::from_utf8_lossy(stdout).lines() { + let Some((key, value)) = line.split_once('=') else { + continue; + }; + let value = value.trim(); + match key { + "platform" => probe.platform = value.to_string(), + "installed" if !value.is_empty() => { + probe.installed_sha256 = Some(value.to_ascii_lowercase()) + } + "kill_user_processes" => probe.kill_user_processes = yes_no(value), + "linger" => probe.linger = yes_no(value), + _ => {} + } + } + probe + } + + pub(crate) fn is_supported(&self) -> bool { + self.platform == "Linux x86_64" + } + + /// Logind ends everything the user started once their last session closes, + /// the keeper included, unless the user lingers. + pub(crate) fn suggests_linger(&self) -> bool { + self.kill_user_processes == Some(true) && self.linger != Some(true) + } +} + +fn yes_no(value: &str) -> Option { + match value { + "yes" => Some(true), + "no" => Some(false), + _ => None, + } +} + +/// The installer's one-word verdict. +pub(crate) fn install_verdict(stdout: &[u8]) -> Result<(), &'static str> { + match last_line(stdout) { + Some("installed") => Ok(()), + Some("checksum_mismatch") => Err("checksum_mismatch"), + Some("checksum_tool_missing") => Err("checksum_tool_missing"), + _ => Err("install_failed"), + } +} + +/// The JSON line `keep start` and `keep stop` print. +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct KeeperReport { + pub(crate) outcome: String, + pub(crate) keeper: Option, + pub(crate) endpoint_url: Option, + pub(crate) auth_token: Option, + pub(crate) app_version: Option, + pub(crate) protocol_version: Option, + pub(crate) error: Option, +} + +impl KeeperReport { + pub(crate) fn parse(stdout: &[u8]) -> Option { + serde_json::from_str(last_line(stdout)?).ok() + } + + /// The keeper's error class, or a fixed word when another build printed + /// something that is not a class and so must not reach the logs. + pub(crate) fn error_class(&self) -> String { + match self.error.as_deref() { + Some(class) if is_class(class) => class.to_string(), + _ => "unrecognized".to_string(), + } + } +} + +fn is_class(text: &str) -> bool { + !text.is_empty() + && text.len() <= 48 + && text + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte == b'_') +} + +fn last_line(stdout: &[u8]) -> Option<&str> { + std::str::from_utf8(stdout) + .ok()? + .lines() + .map(str::trim) + .rfind(|line| !line.is_empty()) +} + +/// The remote endpoint split so that only its port crosses SSH. The App Server +/// listens on loopback; any other host would make this App Shell forward to a +/// machine the user did not choose, so it is refused. +#[derive(Debug, PartialEq, Eq)] +pub(crate) struct RemoteEndpoint { + pub(crate) port: u16, + pub(crate) path: String, +} + +impl RemoteEndpoint { + pub(crate) fn parse(url: &str) -> Option { + let rest = url.strip_prefix("http://")?; + let (authority, path) = rest.split_at(rest.find('/').unwrap_or(rest.len())); + let (host, port) = authority.rsplit_once(':')?; + if !matches!(host, "127.0.0.1" | "localhost" | "[::1]") { + return None; + } + Some(Self { + port: port.parse().ok().filter(|port| *port != 0)?, + path: if path.is_empty() { "/" } else { path }.to_string(), + }) + } +} + +/// Release versions and checksums are interpolated as shell words; refusing +/// anything else keeps them inert even if quoting were wrong. +pub(crate) fn is_version(text: &str) -> bool { + !text.is_empty() + && text.len() <= 64 + && text + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'+')) +} + +pub(crate) fn is_sha256(text: &str) -> bool { + text.len() == 64 + && text + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +#[cfg(test)] +#[path = "remote_tests.rs"] +mod tests; diff --git a/openaide-rs/remote-client/src/ssh/remote_tests.rs b/openaide-rs/remote-client/src/ssh/remote_tests.rs new file mode 100644 index 00000000..f3459d77 --- /dev/null +++ b/openaide-rs/remote-client/src/ssh/remote_tests.rs @@ -0,0 +1,126 @@ +use std::process::Command; + +use super::*; + +const SHA: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +#[test] +fn scripts_survive_every_login_shell_unchanged() { + for script in [INSTALL_DIRECTORY, PROBE, INSTALL, KEEP] { + assert!(!script.contains(['\'', '!', '\n']), "{script}"); + } + assert_eq!(quote("it's"), r"'it'\''s'"); +} + +/// Runs a generated command line the way an SSH server does, through a shell, +/// against a temporary home. +fn run(line: &str, home: &std::path::Path, input: &[u8]) -> Vec { + use std::io::Write; + let mut child = Command::new("sh") + .arg("-c") + .arg(line) + .env("HOME", home) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .spawn() + .unwrap(); + child.stdin.take().unwrap().write_all(input).unwrap(); + child.wait_with_output().unwrap().stdout +} + +#[cfg(unix)] +#[test] +fn installs_only_a_binary_whose_checksum_matches() { + let home = tempfile::tempdir().unwrap(); + let binary = b"#!/bin/sh\necho \"$*\"\n"; + let sha = sha256sum(binary); + + let rejected = run(&install("1.2.3", SHA), home.path(), binary); + assert_eq!(install_verdict(&rejected), Err("checksum_mismatch")); + let installed = home + .path() + .join(".local/share/openaide/app-server/1.2.3/openaide-app-server"); + assert!(!installed.exists()); + + let accepted = run(&install("1.2.3", &sha), home.path(), binary); + assert_eq!(install_verdict(&accepted), Ok(())); + assert_eq!(std::fs::read(&installed).unwrap(), binary); + + let probe = Probe::parse(&run(&probe("1.2.3"), home.path(), b"")); + assert_eq!(probe.installed_sha256.as_deref(), Some(sha.as_str())); + assert!(!probe.platform.is_empty()); + + let kept = run(&keep("1.2.3", &["start", "--join"]), home.path(), b""); + assert_eq!(String::from_utf8_lossy(&kept), "keep start --join\n"); +} + +#[cfg(unix)] +fn sha256sum(bytes: &[u8]) -> String { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("payload"); + std::fs::write(&path, bytes).unwrap(); + let output = Command::new("sha256sum").arg(&path).output().unwrap(); + String::from_utf8(output.stdout).unwrap()[..64].to_string() +} + +#[test] +fn probe_reads_logind_facts() { + let probe = + Probe::parse(b"platform=Linux x86_64\ninstalled=ABC\nkill_user_processes=yes\nlinger=no\n"); + + assert_eq!( + probe, + Probe { + platform: "Linux x86_64".into(), + installed_sha256: Some("abc".into()), + kill_user_processes: Some(true), + linger: Some(false), + } + ); + assert!(probe.is_supported()); + assert!(probe.suggests_linger()); + assert!(!Probe::parse(b"platform=Linux aarch64\nkill_user_processes=\n").is_supported()); + assert!(!Probe::parse(b"platform=Linux x86_64\nkill_user_processes=\n").suggests_linger()); +} + +#[test] +fn keeper_report_is_the_last_line_and_its_error_is_a_class() { + let report = + KeeperReport::parse(b"motd noise\n{\"outcome\":\"failed\",\"error\":\"keeper_busy\"}\n\n") + .unwrap(); + assert_eq!(report.outcome, "failed"); + assert_eq!(report.error_class(), "keeper_busy"); + + let odd = + KeeperReport::parse(br#"{"outcome":"failed","error":"/home/someone: denied"}"#).unwrap(); + assert_eq!(odd.error_class(), "unrecognized"); + assert!(KeeperReport::parse(b"sh: not found\n").is_none()); +} + +#[test] +fn only_a_loopback_endpoint_is_forwarded() { + assert_eq!( + RemoteEndpoint::parse("http://127.0.0.1:43123/rpc"), + Some(RemoteEndpoint { + port: 43123, + path: "/rpc".into() + }) + ); + assert_eq!( + RemoteEndpoint::parse("http://[::1]:8080").map(|endpoint| endpoint.path), + Some("/".into()) + ); + assert_eq!(RemoteEndpoint::parse("http://10.0.0.5:43123/rpc"), None); + assert_eq!(RemoteEndpoint::parse("https://127.0.0.1:43123/"), None); + assert_eq!(RemoteEndpoint::parse("http://127.0.0.1:0/"), None); +} + +#[test] +fn only_inert_versions_and_checksums_are_accepted() { + assert!(is_version("0.11.0-rc.1+build.5")); + assert!(!is_version("0.11.0; rm -rf ~")); + assert!(!is_version("")); + assert!(is_sha256(SHA)); + assert!(!is_sha256(&SHA.to_uppercase())); + assert!(!is_sha256(&SHA[1..])); +} diff --git a/openaide-rs/remote-client/src/ssh/version.rs b/openaide-rs/remote-client/src/ssh/version.rs new file mode 100644 index 00000000..c4f9c07b --- /dev/null +++ b/openaide-rs/remote-client/src/ssh/version.rs @@ -0,0 +1,75 @@ +//! What an App Shell may do with an App Server of another version that already +//! holds the SSH Computer's state root (ADR-0069): attach to an older one that +//! still speaks its protocol, replace an older one only with the user's +//! confirmation, and never replace a newer one. + +use std::cmp::Ordering; + +use openaide_app_server_protocol::snapshot::ProtocolVersion; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Running { + /// Older, and it accepts this App Shell's protocol: attach and offer an update. + OlderCompatible, + /// Older, and this App Shell cannot use it: only a confirmed update helps. + OlderIncompatible, + /// Newer: replacing it would downgrade the computer. + Newer, + /// The versions cannot be ordered, so neither attaching nor replacing is safe. + Unknown, +} + +pub(crate) fn classify( + shell_version: &str, + shell_protocol: ProtocolVersion, + running_version: &str, + running_protocol: Option<&str>, +) -> Running { + match compare(running_version, shell_version) { + Some(Ordering::Greater) => Running::Newer, + Some(Ordering::Less) => { + let accepts = running_protocol + .and_then(|text| text.parse::().ok()) + .is_some_and(|running| running.accepts_client(shell_protocol)); + if accepts { + Running::OlderCompatible + } else { + Running::OlderIncompatible + } + } + // An equal version cannot be the mismatch the keeper reported. + Some(Ordering::Equal) | None => Running::Unknown, + } +} + +/// Orders release versions `major.minor.patch[-pre][+build]`. A pre-release is +/// older than its release; two different pre-releases of one release are not +/// ordered, because nothing here needs to choose between them. +pub(crate) fn compare(left: &str, right: &str) -> Option { + let (left_core, left_pre) = parse(left)?; + let (right_core, right_pre) = parse(right)?; + match left_core.cmp(&right_core) { + Ordering::Equal => match (left_pre, right_pre) { + (None, None) => Some(Ordering::Equal), + (None, Some(_)) => Some(Ordering::Greater), + (Some(_), None) => Some(Ordering::Less), + (Some(left), Some(right)) => (left == right).then_some(Ordering::Equal), + }, + ordering => Some(ordering), + } +} + +fn parse(version: &str) -> Option<([u64; 3], Option<&str>)> { + let version = version.split_once('+').map_or(version, |(core, _)| core); + let (core, pre) = match version.split_once('-') { + Some((core, pre)) => (core, Some(pre)), + None => (version, None), + }; + let mut parts = core.split('.').map(|part| part.parse::().ok()); + let parsed = [parts.next()??, parts.next()??, parts.next()??]; + parts.next().is_none().then_some((parsed, pre)) +} + +#[cfg(test)] +#[path = "version_tests.rs"] +mod tests; diff --git a/openaide-rs/remote-client/src/ssh/version_tests.rs b/openaide-rs/remote-client/src/ssh/version_tests.rs new file mode 100644 index 00000000..3a5cf12d --- /dev/null +++ b/openaide-rs/remote-client/src/ssh/version_tests.rs @@ -0,0 +1,45 @@ +use super::*; + +const SHELL: ProtocolVersion = ProtocolVersion { major: 2, minor: 2 }; + +#[test] +fn orders_releases_and_their_pre_releases() { + assert_eq!(compare("0.10.0", "0.9.12"), Some(Ordering::Greater)); + assert_eq!(compare("0.10.0", "0.10.0+build.7"), Some(Ordering::Equal)); + assert_eq!(compare("0.11.0-rc.1", "0.11.0"), Some(Ordering::Less)); + assert_eq!(compare("0.11.0-rc.1", "0.11.0-rc.2"), None); + assert_eq!(compare("0.11", "0.11.0"), None); + assert_eq!(compare("dev", "0.11.0"), None); +} + +#[test] +fn an_older_server_that_accepts_the_shell_protocol_can_be_joined() { + assert_eq!( + classify("0.11.0", SHELL, "0.10.1", Some("2.3")), + Running::OlderCompatible + ); + assert_eq!( + classify("0.11.0", SHELL, "0.10.1", Some("2.1")), + Running::OlderIncompatible + ); + assert_eq!( + classify("0.11.0", SHELL, "0.10.1", None), + Running::OlderIncompatible + ); +} + +#[test] +fn a_newer_or_unordered_server_is_never_replaced() { + assert_eq!( + classify("0.11.0", SHELL, "0.12.0", Some("2.2")), + Running::Newer + ); + assert_eq!( + classify("0.11.0", SHELL, "custom", Some("2.2")), + Running::Unknown + ); + assert_eq!( + classify("0.11.0", SHELL, "0.11.0", Some("2.2")), + Running::Unknown + ); +} diff --git a/openaide-rs/remote-client/src/ssh_tests.rs b/openaide-rs/remote-client/src/ssh_tests.rs new file mode 100644 index 00000000..165cf2b6 --- /dev/null +++ b/openaide-rs/remote-client/src/ssh_tests.rs @@ -0,0 +1,320 @@ +use std::collections::VecDeque; +use std::sync::{Arc, Mutex}; + +use serde_json::Value; + +use super::*; + +const SHA: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +const VERSION: &str = "0.11.0"; +const LOCAL: &str = "127.0.0.1:50001"; + +/// Answers each command with the next scripted reply after checking that the +/// command is the expected one. +struct FakeSsh { + replies: VecDeque<(&'static str, Result)>, + uploaded: Option>, + forwarded: Option, +} + +impl FakeSsh { + fn new(replies: Vec<(&'static str, Result)>) -> Self { + Self { + replies: replies.into(), + uploaded: None, + forwarded: None, + } + } +} + +impl SshTransport for FakeSsh { + type Forward = u16; + + fn run( + &mut self, + command: &str, + input: Option<&mut dyn Read>, + ) -> Result { + let (expected, reply) = self.replies.pop_front().expect("an unexpected command"); + assert!( + command.contains(expected), + "expected {expected:?} in {command}" + ); + if let Some(input) = input { + let mut bytes = Vec::new(); + input.read_to_end(&mut bytes).unwrap(); + self.uploaded = Some(bytes); + } + reply.map(|stdout| RemoteOutput { + success: true, + stdout: stdout.into_bytes(), + }) + } + + fn forward(&mut self, remote_port: u16) -> Result<(u16, SocketAddr), TransportError> { + self.forwarded = Some(remote_port); + Ok((remote_port, LOCAL.parse().unwrap())) + } +} + +type Events = Arc>>; + +fn launcher() -> (SshLauncher, Events, Arc>>) { + let events: Events = Arc::default(); + let stages: Arc>> = Arc::default(); + let sink = events.clone(); + let seen = stages.clone(); + let launcher = SshLauncher::new( + ServerRelease::new(VERSION, SHA).unwrap(), + Arc::new(move |name, fields| sink.lock().unwrap().push((name, fields))), + Box::new(move |stage| seen.lock().unwrap().push(stage)), + ); + (launcher, events, stages) +} + +fn probe(installed: &str) -> (&'static str, Result) { + ( + "uname -sm", + Ok(format!( + "platform=Linux x86_64\ninstalled={installed}\nkill_user_processes=yes\nlinger=no\n" + )), + ) +} + +/// The protocol this shell speaks, so an older App Server on it still accepts the shell. +fn current() -> String { + APP_SERVER_PROTOCOL_VERSION.to_string() +} + +fn report(outcome: &str, app_version: &str, protocol: &str) -> Result { + Ok(json!({ + "outcome": outcome, + "keeper": "new", + "endpointUrl": "http://127.0.0.1:43123/rpc", + "authToken": "secret-token", + "appVersion": app_version, + "protocolVersion": protocol, + }) + .to_string()) +} + +fn no_fetch() -> Result, &'static str> { + panic!("the installed binary should have been used") +} + +fn ready(launched: Result, LaunchError>) -> Ready { + match launched { + Ok(Launched::Ready(ready)) => ready, + Ok(Launched::UpdateRequired { running_version }) => { + panic!("update required from {running_version}") + } + Err(error) => panic!("{error}"), + } +} + +#[test] +fn installs_a_missing_release_then_forwards_the_kept_app_server() { + let (launcher, events, stages) = launcher(); + let mut ssh = FakeSsh::new(vec![ + probe(""), + ("openaide-app-server.upload", Ok("installed\n".into())), + ( + "keep \"$@\"' openaide '0.11.0' 'start'", + report("started", VERSION, ¤t()), + ), + ]); + let mut fetched = 0; + let mut fetch = || -> Result, &'static str> { + fetched += 1; + Ok(Box::new(&b"binary"[..])) + }; + + let ready = ready(launcher.launch(&mut ssh, StartMode::Keep, &mut fetch, "op-1")); + + assert_eq!(fetched, 1); + assert_eq!(ssh.uploaded.as_deref(), Some(&b"binary"[..])); + assert_eq!(ssh.forwarded, Some(43123)); + assert_eq!(ready.endpoint_url, format!("http://{LOCAL}/rpc")); + assert_eq!(ready.auth_token, "secret-token"); + assert!(!ready.update_available && !ready.keeper_existed); + assert!(ready.suggest_linger); + assert_eq!( + *stages.lock().unwrap(), + [ + Stage::Probe, + Stage::Fetch, + Stage::Upload, + Stage::Start, + Stage::Forward + ] + ); + let events = events.lock().unwrap(); + let (name, completed) = events.last().unwrap(); + assert_eq!(*name, "ssh_launch_completed"); + assert_eq!(completed["outcome"], "ready"); + assert_eq!(completed["operationId"], "op-1"); + let started = events + .iter() + .filter(|(name, _)| *name == "ssh_launch_stage_started") + .count(); + let finished = events + .iter() + .filter(|(name, _)| *name == "ssh_launch_stage_completed") + .count(); + assert_eq!((started, finished), (5, 5)); + assert!(!format!("{events:?}").contains("secret-token")); +} + +#[test] +fn an_installed_release_is_not_fetched_again() { + let (launcher, _, stages) = launcher(); + let mut ssh = FakeSsh::new(vec![ + probe(SHA), + ("keep", report("attached", VERSION, ¤t())), + ]); + + ready(launcher.launch(&mut ssh, StartMode::Keep, &mut no_fetch, "op-1")); + + assert_eq!( + *stages.lock().unwrap(), + [Stage::Probe, Stage::Start, Stage::Forward] + ); +} + +#[test] +fn an_older_app_server_that_speaks_the_protocol_is_joined_and_offered_an_update() { + let (launcher, _, _) = launcher(); + let mut ssh = FakeSsh::new(vec![ + probe(SHA), + ("'start'", report("mismatch", "0.10.1", ¤t())), + ("'start' '--join'", report("joined", "0.10.1", ¤t())), + ]); + + let ready = ready(launcher.launch(&mut ssh, StartMode::Keep, &mut no_fetch, "op-1")); + + assert!(ready.update_available); + assert_eq!(ready.app_version, "0.10.1"); +} + +#[test] +fn an_older_app_server_this_shell_cannot_use_needs_a_confirmed_update() { + let (launcher, _, _) = launcher(); + let mut ssh = FakeSsh::new(vec![ + probe(SHA), + ("'start'", report("mismatch", "0.10.0", "2.1")), + ]); + + let launched = launcher.launch(&mut ssh, StartMode::Keep, &mut no_fetch, "op-1"); + + assert!(matches!( + launched, + Ok(Launched::UpdateRequired { ref running_version }) if running_version == "0.10.0" + )); + assert_eq!(ssh.forwarded, None); + + let mut ssh = FakeSsh::new(vec![ + probe(SHA), + ( + "'start' '--replace'", + report("started", VERSION, ¤t()), + ), + ]); + ready(launcher.launch(&mut ssh, StartMode::Replace, &mut no_fetch, "op-2")); +} + +#[test] +fn a_newer_app_server_is_never_replaced() { + let (launcher, events, _) = launcher(); + let mut ssh = FakeSsh::new(vec![ + probe(SHA), + ("'start'", report("mismatch", "0.12.0", "2.3")), + ]); + + let error = launcher + .launch(&mut ssh, StartMode::Keep, &mut no_fetch, "op-1") + .err() + .unwrap(); + + assert_eq!( + error, + LaunchError::ServerNewer { + running_version: "0.12.0".into() + } + ); + let events = events.lock().unwrap(); + let (_, completed) = events.last().unwrap(); + assert_eq!(completed["stage"], "start"); + assert_eq!(completed["errorClass"], "server_newer"); +} + +#[test] +fn failures_are_classified_by_stage() { + let (launcher, _, _) = launcher(); + let unsupported = FakeSsh::new(vec![("uname", Ok("platform=Darwin arm64\n".into()))]); + let unreachable = FakeSsh::new(vec![( + "uname", + Err(TransportError { + class: "unreachable", + }), + )]); + let corrupted = FakeSsh::new(vec![ + probe(""), + ("upload", Ok("checksum_mismatch\n".into())), + ]); + let busy = FakeSsh::new(vec![ + probe(SHA), + ( + "keep", + Ok(r#"{"outcome":"failed","error":"keeper_busy"}"#.into()), + ), + ]); + let elsewhere = FakeSsh::new(vec![ + probe(SHA), + ( + "keep", + Ok( + r#"{"outcome":"started","endpointUrl":"http://10.0.0.5:1/","authToken":"t"}"# + .into(), + ), + ), + ]); + let mut fetch = || -> Result, &'static str> { Ok(Box::new(&b""[..])) }; + + let mut classes = Vec::new(); + for mut ssh in [unsupported, unreachable, corrupted, busy, elsewhere] { + let error = launcher + .launch(&mut ssh, StartMode::Keep, &mut fetch, "op") + .err() + .unwrap(); + classes.push((error.stage(), error.class().to_string())); + } + + assert_eq!( + classes, + [ + (Stage::Probe, "unsupported_platform".to_string()), + (Stage::Probe, "unreachable".to_string()), + (Stage::Upload, "checksum_mismatch".to_string()), + (Stage::Start, "keeper_busy".to_string()), + (Stage::Start, "unreadable_answer".to_string()), + ] + ); +} + +#[test] +fn stop_reports_what_the_keeper_did() { + let (launcher, events, _) = launcher(); + let mut ssh = FakeSsh::new(vec![("'stop'", Ok(r#"{"outcome":"stopped"}"#.into()))]); + + assert_eq!(launcher.stop(&mut ssh, "op-3"), Ok(StopOutcome::Stopped)); + let events = events.lock().unwrap(); + assert_eq!(events[0].0, "ssh_stop_completed"); + assert_eq!(events[0].1["outcome"], "stopped"); +} + +#[test] +fn a_release_must_be_safe_to_name_in_a_command() { + assert!(ServerRelease::new("0.11.0", SHA).is_some()); + assert!(ServerRelease::new("0.11.0 && true", SHA).is_none()); + assert!(ServerRelease::new("0.11.0", "abc").is_none()); +} diff --git a/packages/app-server-client/src/appServerSession.ts b/packages/app-server-client/src/appServerSession.ts index 496ea4d4..9b619dd2 100644 --- a/packages/app-server-client/src/appServerSession.ts +++ b/packages/app-server-client/src/appServerSession.ts @@ -468,6 +468,7 @@ function scopeKey(scope: SubscriptionScope) { case "projects": case "agents": case "devices": + case "terminals": return scope.kind; case "settings": return `settings:${scope.section ?? ""}`; diff --git a/packages/app-server-client/src/generated/protocol.ts b/packages/app-server-client/src/generated/protocol.ts index 78263108..52195ecb 100644 --- a/packages/app-server-client/src/generated/protocol.ts +++ b/packages/app-server-client/src/generated/protocol.ts @@ -13,6 +13,7 @@ export const CLIENT_DETACH = "client/detach" as const; export const CLIENT_UPDATE_SHUTDOWN_PREPARE = "client/updateShutdownPrepare" as const; export const CLIENT_UPDATE_SHUTDOWN_COMMIT = "client/updateShutdownCommit" as const; export const CLIENT_UPDATE_SHUTDOWN_ABORT = "client/updateShutdownAbort" as const; +export const CLIENT_REPLACEMENT_IMPACT = "client/replacementImpact" as const; export const PENDING_REQUEST_RESOLVE = "pendingRequest/resolve" as const; @@ -61,6 +62,9 @@ export const DEVICES_PREVIEW_JOIN_REQUEST = "devices/previewJoinRequest" as cons export const DEVICES_APPROVE_JOIN_REQUEST = "devices/approveJoinRequest" as const; export const DEVICES_REMOVE = "devices/remove" as const; export const TASK_ATTENTION_WAIT = "taskAttention/wait" as const; +export const TERMINALS_OPEN = "terminals/open" as const; +export const TERMINALS_ATTACH = "terminals/attach" as const; +export const TERMINALS_CLOSE = "terminals/close" as const; export const ATTACHMENT_LIST_ROOTS = "attachment/listRoots" as const; export const ATTACHMENT_LIST_DIRECTORY = "attachment/listDirectory" as const; export const ATTACHMENT_CREATE_FILE_REFERENCE = "attachment/createFileReference" as const; @@ -251,6 +255,34 @@ export type UpdateShutdownAbortParams = { attemptId: string, }; export type UpdateShutdownAbortResult = Record; +export type ClientReplacementImpactParams = Record; + +export type ClientReplacementImpactResult = { +/** + * Initialized clients other than the caller, including those inside their reconnect + * grace, which a replacement would also disconnect. + */ +otherClients?: Array, activeTurns: number, pendingTaskRequests: number, +/** + * Terminals whose shells a replacement would end (ADR-0071). Absent from App Servers + * before protocol 2.3. + */ +openTerminals: number, }; + +export type AttachedClientSummary = { kind: ShellKind, name?: string | null, +/** + * The product version the client reported. + */ +appVersion?: string | null, +/** + * Omitted by clients installed together with their App Server. + */ +protocolVersion?: ProtocolVersion | null, +/** + * The client stopped responding and is inside its reconnect grace. + */ +reconnecting: boolean, }; + export type ClientProbeLifecycle = "running" | "draining" | "stopping"; export type InitializeParams = { clientInstanceId: ClientInstanceId, shell: ShellDescriptor, requestedSurface: RequestedSurface, capabilities?: ClientCapabilities, @@ -288,7 +320,7 @@ export type ClientWorkspaceRoot = { path: string, }; export type ShellDescriptor = { kind: ShellKind, name?: string | null, version?: string | null, }; -export type ShellKind = "web" | "desktop" | "mobile" | "vscodeExtension" | "terminal"; +export type ShellKind = "web" | "desktop" | "mobile" | "vscodeExtension" | "terminal" | "keeper"; export type RequestedSurface = { "kind": "home" } | { "kind": "project", projectId: ProjectId, } | { "kind": "newTask", projectId?: ProjectId | null, } | { "kind": "task", taskId: TaskId, } | { "kind": "settings", section?: SettingsSection | null, }; @@ -308,9 +340,9 @@ export type StateUnsubscribeParams = { scope: SubscriptionScope, }; export type StateUnsubscribeResult = { scope: SubscriptionScope, }; -export type SubscriptionScope = { "kind": "projects" } | { "kind": "agents" } | { "kind": "settings", section?: SettingsSection | null, } | { "kind": "taskNavigation", section: TaskNavigationSection, projectIds?: Array | null, } | { "kind": "task", taskId: TaskId, } | { "kind": "subagentCatalog", taskId: TaskId, } | { "kind": "subagentHistory", taskId: TaskId, subagentId: SubagentId, } | { "kind": "toolDetail", taskId: TaskId, artifactId: string, } | { "kind": "worktreeRepository", repositoryId: WorktreeRepositoryId, } | { "kind": "devices" }; +export type SubscriptionScope = { "kind": "projects" } | { "kind": "agents" } | { "kind": "settings", section?: SettingsSection | null, } | { "kind": "taskNavigation", section: TaskNavigationSection, projectIds?: Array | null, } | { "kind": "task", taskId: TaskId, } | { "kind": "subagentCatalog", taskId: TaskId, } | { "kind": "subagentHistory", taskId: TaskId, subagentId: SubagentId, } | { "kind": "toolDetail", taskId: TaskId, artifactId: string, } | { "kind": "worktreeRepository", repositoryId: WorktreeRepositoryId, } | { "kind": "devices" } | { "kind": "terminals" }; -export type SubscriptionSnapshot = { "kind": "projects", projects: ProjectCollectionSnapshot, } | { "kind": "agents", agents: AgentCollectionSnapshot, } | { "kind": "settings", settings: SettingsSnapshot, } | { "kind": "taskNavigation", navigation: TaskNavigationSnapshot, } | { "kind": "task", task: TaskSnapshot, } | { "kind": "subagentCatalog", catalog: SubagentCatalogSnapshot, } | { "kind": "subagentHistory", history: SubagentHistorySnapshot, } | { "kind": "toolDetail", taskId: TaskId, artifactId: string, details: ToolDetailSnapshot, } | { "kind": "worktreeRepository", repository: WorktreeRepositorySnapshot, } | { "kind": "devices", devices: DeviceCollectionSnapshot, }; +export type SubscriptionSnapshot = { "kind": "projects", projects: ProjectCollectionSnapshot, } | { "kind": "agents", agents: AgentCollectionSnapshot, } | { "kind": "settings", settings: SettingsSnapshot, } | { "kind": "taskNavigation", navigation: TaskNavigationSnapshot, } | { "kind": "task", task: TaskSnapshot, } | { "kind": "subagentCatalog", catalog: SubagentCatalogSnapshot, } | { "kind": "subagentHistory", history: SubagentHistorySnapshot, } | { "kind": "toolDetail", taskId: TaskId, artifactId: string, details: ToolDetailSnapshot, } | { "kind": "worktreeRepository", repository: WorktreeRepositorySnapshot, } | { "kind": "devices", devices: DeviceCollectionSnapshot, } | { "kind": "terminals", terminals: TerminalCollectionSnapshot, }; export type SubagentOverviewSnapshot = { totalCount: number, runningCount: number, attentionCount: number, available?: boolean, }; @@ -648,6 +680,48 @@ cursor: string, */ tasks: Array, }; +export type TerminalTarget = { "kind": "project", projectId: ProjectId, } | { "kind": "task", taskId: TaskId, }; + +export type TerminalStatus = "idle" | "busy" | "exited"; + +export type TerminalSummary = { terminalId: string, +/** + * The Project the Terminal was opened from, for grouping and counts. + */ +projectId: ProjectId, +/** + * The folder the shell started in, for display. + */ +folder: string, +/** + * The foreground command's name while one runs, when the platform reports it. + */ +command?: string | null, status: TerminalStatus, exitCode?: number | null, +/** + * Milliseconds since the Unix epoch; orders tabs identically on every client. + */ +openedAt: number, }; + +export type TerminalCollectionSnapshot = { terminals: Array, }; + +export type TerminalAttach = { terminalId: string, token: string, +/** + * Path on the App Server's local HTTP origin that accepts the WebSocket upgrade. + */ +path: string, }; + +export type TerminalsOpenParams = { target: TerminalTarget, cols: number, rows: number, }; + +export type TerminalsOpenResult = { terminal: TerminalSummary, attach: TerminalAttach, }; + +export type TerminalsAttachParams = { terminalId: string, }; + +export type TerminalsAttachResult = { attach: TerminalAttach, }; + +export type TerminalsCloseParams = { terminalId: string, force: boolean, }; + +export type TerminalsCloseResult = Record; + export type RuntimeDeveloperSettings = { acpTrace: RuntimeAcpTraceSettings, }; export type RuntimeAcpTraceSettings = { enabled: boolean, directory: string, }; @@ -1190,7 +1264,7 @@ subscription: SubscriptionScope, previousCursor: EventCursor, cursor: EventCurso export type EventScope = { "kind": "stateRoot", stateRootId: StateRootId, } | { "kind": "client", stateRootId: StateRootId, clientInstanceId: ClientInstanceId, } | { "kind": "task", stateRootId: StateRootId, taskId: TaskId, }; -export type AppServerEventPayload = { "kind": "snapshotReplaced", snapshot: ClientSnapshot, } | { "kind": "taskChanged", taskId: TaskId, revision: number, changes: TaskChanges, } | { "kind": "taskHistorySyncUpdated", taskId: TaskId, historySync: TaskHistorySyncSnapshot, } | { "kind": "taskUpdated", projectId: ProjectId, task: TaskSummary, } | { "kind": "projectEntriesReplaced", section: TaskNavigationSection, projectId: ProjectId, taskCount: number, entries: Array, hasMore: boolean, loading: boolean, } | { "kind": "refreshStateChanged", refresh: TaskNavigationRefreshState, } | { "kind": "navigationReplaced", navigation: TaskNavigationSnapshot, } | { "kind": "projectCollectionUpdated", projects: ProjectCollectionSnapshot, } | { "kind": "taskRequestsUpdated", taskId: TaskId, requests: Array, } | { "kind": "subagentCatalogUpdated", catalog: SubagentCatalogSnapshot, } | { "kind": "subagentHistoryUpdated", history: SubagentHistorySnapshot, } | { "kind": "toolDetailUpdated", taskId: TaskId, artifactId: string, details: ToolDetailSnapshot, } | { "kind": "toolDetailChanged", taskId: TaskId, artifactId: string, revision: number, deltas: Array, } | { "kind": "requestUpdated", request: PendingRequestSnapshot, } | { "kind": "agentCollectionUpdated", agents: AgentCollectionSnapshot, } | { "kind": "worktreeRepositoryUpdated", repositoryId: WorktreeRepositoryId, repository: WorktreeRepositorySnapshot, } | { "kind": "deviceCollectionUpdated", devices: DeviceCollectionSnapshot, }; +export type AppServerEventPayload = { "kind": "snapshotReplaced", snapshot: ClientSnapshot, } | { "kind": "taskChanged", taskId: TaskId, revision: number, changes: TaskChanges, } | { "kind": "taskHistorySyncUpdated", taskId: TaskId, historySync: TaskHistorySyncSnapshot, } | { "kind": "taskUpdated", projectId: ProjectId, task: TaskSummary, } | { "kind": "projectEntriesReplaced", section: TaskNavigationSection, projectId: ProjectId, taskCount: number, entries: Array, hasMore: boolean, loading: boolean, } | { "kind": "refreshStateChanged", refresh: TaskNavigationRefreshState, } | { "kind": "navigationReplaced", navigation: TaskNavigationSnapshot, } | { "kind": "projectCollectionUpdated", projects: ProjectCollectionSnapshot, } | { "kind": "taskRequestsUpdated", taskId: TaskId, requests: Array, } | { "kind": "subagentCatalogUpdated", catalog: SubagentCatalogSnapshot, } | { "kind": "subagentHistoryUpdated", history: SubagentHistorySnapshot, } | { "kind": "toolDetailUpdated", taskId: TaskId, artifactId: string, details: ToolDetailSnapshot, } | { "kind": "toolDetailChanged", taskId: TaskId, artifactId: string, revision: number, deltas: Array, } | { "kind": "requestUpdated", request: PendingRequestSnapshot, } | { "kind": "agentCollectionUpdated", agents: AgentCollectionSnapshot, } | { "kind": "worktreeRepositoryUpdated", repositoryId: WorktreeRepositoryId, repository: WorktreeRepositorySnapshot, } | { "kind": "deviceCollectionUpdated", devices: DeviceCollectionSnapshot, } | { "kind": "terminalCollectionUpdated", terminals: TerminalCollectionSnapshot, }; export type ToolDetailDelta = { "kind": "replaceDetails", details: ToolDetailSnapshot, } | { "kind": "appendTerminal", terminalId: string, data: string, }; @@ -1552,7 +1626,7 @@ export type PendingRequestScope = { "kind": "client", clientInstanceId: ClientIn export type PendingRequestKind = "permission" | "question" | "secret" | "shellCapability"; -export type ProtocolMethod = typeof CLIENT_PROBE | typeof CLIENT_INITIALIZE | typeof CLIENT_CAPABILITIES_CHANGED | typeof CLIENT_HEARTBEAT | typeof CLIENT_DETACH | typeof PENDING_REQUEST_RESOLVE | typeof STATE_SUBSCRIBE | typeof STATE_UNSUBSCRIBE | typeof DIAGNOSTICS_GET_RUNTIME | typeof SUPPORT_RECOVER_STUCK_SESSIONS | typeof AGENT_PROBE | typeof AGENT_AUTHENTICATE | typeof AGENT_LIST_SESSIONS | typeof AGENT_CREATE_CUSTOM | typeof AGENT_UPDATE_CUSTOM_METADATA | typeof AGENT_REPLACE_CUSTOM | typeof AGENT_DELETE_CUSTOM | typeof AGENT_SET_ENABLED | typeof SETTINGS_GET_AGENT_DETAILS | typeof SETTINGS_GET_MCP_SERVERS | typeof MCP_GET_SERVER_DETAILS | typeof MCP_CREATE_SERVER | typeof MCP_UPDATE_SERVER | typeof MCP_DELETE_SERVER | typeof MCP_SET_SERVER_ENABLED | typeof SETTINGS_GET_SKILLS | typeof SETTINGS_GET_SKILL_DETAILS | typeof SETTINGS_GET_PREFERENCES | typeof SETTINGS_UPDATE_PREFERENCES | typeof SETTINGS_UPDATE_NEW_TASK_DEFAULTS | typeof SETTINGS_GET_RUNTIME | typeof SETTINGS_UPDATE_RUNTIME | typeof ATTACHMENT_LIST_ROOTS | typeof ATTACHMENT_LIST_DIRECTORY | typeof ATTACHMENT_CREATE_FILE_REFERENCE | typeof ATTACHMENT_CREATE_LOCAL_FILE_REFERENCES | typeof ATTACHMENT_CREATE_PASTED_IMAGE | typeof ATTACHMENT_CREATE_EMBEDDED_CANDIDATE | typeof ATTACHMENT_CONFIRM_EMBEDDED | typeof ATTACHMENT_REFRESH_HANDLES | typeof ATTACHMENT_RELEASE | typeof ATTACHMENT_REVEAL | typeof ATTACHMENT_REVEAL_SENT | typeof SHELL_RESOLVE_FILE_REVEAL | typeof WORKSPACE_LIST_ROOTS | typeof WORKSPACE_LIST_DIRECTORY | typeof WORKTREE_REFRESH | typeof WORKTREE_CREATE | typeof WORKTREE_RECREATE | typeof WORKTREE_REMOVAL_PREFLIGHT | typeof WORKTREE_REMOVE | typeof WORKTREE_RENAME | typeof WORKTREE_RESOLVE_FOLDER | typeof WORKTREE_LINKED_TASKS | typeof TASK_ACQUIRE | typeof TASK_ACQUIRE_IN_WORKTREE | typeof TASK_SEARCH_FILES | typeof TASK_ADOPT_NATIVE_SESSION | typeof TASK_SEND | typeof TASK_RESOLVE_CONFIG_PREFERENCES | typeof TASK_SET_CONFIG_OPTION | typeof TASK_SET_TITLE | typeof TASK_CANCEL | typeof TASK_STOP_BACKGROUND_COMMAND | typeof TASK_STOP_SUBAGENT | typeof TASK_OPEN | typeof TASK_MARK_READ | typeof TASK_CHAT_PAGE | typeof TASK_LIST | typeof TASK_NAVIGATION_REFRESH | typeof TASK_NAVIGATION_LOAD_MORE | typeof NATIVE_SESSION_ARCHIVE | typeof NATIVE_SESSION_SET_TITLE | typeof NATIVE_SESSION_SET_PINNED | typeof NATIVE_SESSION_RESTORE | typeof TASK_RELEASE | typeof TASK_ARCHIVE | typeof TASK_RESTORE | typeof CLIENT_UPDATE_SHUTDOWN_PREPARE | typeof CLIENT_UPDATE_SHUTDOWN_COMMIT | typeof CLIENT_UPDATE_SHUTDOWN_ABORT | typeof DIAGNOSTICS_LIST_SUPPORT_EXPORT | typeof DIAGNOSTICS_CREATE_SUPPORT_EXPORT | typeof PROJECT_ADD | typeof PROJECT_RENAME | typeof PROJECT_REMOVE | typeof PROJECT_REFRESH | typeof TASK_QUEUE_APPEND | typeof TASK_QUEUE_REMOVE | typeof TASK_QUEUE_TAKE | typeof TASK_QUEUE_MOVE | typeof TASK_SET_PERMISSION_POLICY | typeof TASK_SET_PINNED | typeof TASK_CLOSE_PLAN | typeof TASK_TOOL_IMAGE_PREVIEW | typeof FILE_VIEWER_LIST_DIRECTORY | typeof FILE_VIEWER_SEARCH | typeof FILE_VIEWER_CHANGES | typeof FILE_VIEWER_DIFF | typeof FILE_VIEWER_OPEN | typeof FILE_VIEWER_OPEN_FROM_HANDLE | typeof FILE_VIEWER_REFRESH | typeof FILE_VIEWER_RELEASE | typeof TASK_COMPOSER_HISTORY | typeof SETTINGS_RESET_TASK_HISTORY | typeof NATIVE_SESSION_DELETE | typeof NATIVE_SESSION_FORK | typeof TASK_RELOAD_NATIVE_SESSION | typeof TASK_ARCHIVE_OLDER | typeof AGENT_CANCEL_AUTHENTICATE | typeof AGENT_LOGOUT | typeof AGENT_REFRESH_ACCOUNT_LIMITS | typeof TASK_QUEUE_RESUME | typeof DEVICES_CREATE_INVITE | typeof DEVICES_CANCEL_INVITE | typeof DEVICES_PREVIEW_JOIN_REQUEST | typeof DEVICES_APPROVE_JOIN_REQUEST | typeof DEVICES_REMOVE | typeof TASK_ATTENTION_WAIT; +export type ProtocolMethod = typeof CLIENT_PROBE | typeof CLIENT_INITIALIZE | typeof CLIENT_CAPABILITIES_CHANGED | typeof CLIENT_HEARTBEAT | typeof CLIENT_DETACH | typeof PENDING_REQUEST_RESOLVE | typeof STATE_SUBSCRIBE | typeof STATE_UNSUBSCRIBE | typeof DIAGNOSTICS_GET_RUNTIME | typeof SUPPORT_RECOVER_STUCK_SESSIONS | typeof AGENT_PROBE | typeof AGENT_AUTHENTICATE | typeof AGENT_LIST_SESSIONS | typeof AGENT_CREATE_CUSTOM | typeof AGENT_UPDATE_CUSTOM_METADATA | typeof AGENT_REPLACE_CUSTOM | typeof AGENT_DELETE_CUSTOM | typeof AGENT_SET_ENABLED | typeof SETTINGS_GET_AGENT_DETAILS | typeof SETTINGS_GET_MCP_SERVERS | typeof MCP_GET_SERVER_DETAILS | typeof MCP_CREATE_SERVER | typeof MCP_UPDATE_SERVER | typeof MCP_DELETE_SERVER | typeof MCP_SET_SERVER_ENABLED | typeof SETTINGS_GET_SKILLS | typeof SETTINGS_GET_SKILL_DETAILS | typeof SETTINGS_GET_PREFERENCES | typeof SETTINGS_UPDATE_PREFERENCES | typeof SETTINGS_UPDATE_NEW_TASK_DEFAULTS | typeof SETTINGS_GET_RUNTIME | typeof SETTINGS_UPDATE_RUNTIME | typeof ATTACHMENT_LIST_ROOTS | typeof ATTACHMENT_LIST_DIRECTORY | typeof ATTACHMENT_CREATE_FILE_REFERENCE | typeof ATTACHMENT_CREATE_LOCAL_FILE_REFERENCES | typeof ATTACHMENT_CREATE_PASTED_IMAGE | typeof ATTACHMENT_CREATE_EMBEDDED_CANDIDATE | typeof ATTACHMENT_CONFIRM_EMBEDDED | typeof ATTACHMENT_REFRESH_HANDLES | typeof ATTACHMENT_RELEASE | typeof ATTACHMENT_REVEAL | typeof ATTACHMENT_REVEAL_SENT | typeof SHELL_RESOLVE_FILE_REVEAL | typeof WORKSPACE_LIST_ROOTS | typeof WORKSPACE_LIST_DIRECTORY | typeof WORKTREE_REFRESH | typeof WORKTREE_CREATE | typeof WORKTREE_RECREATE | typeof WORKTREE_REMOVAL_PREFLIGHT | typeof WORKTREE_REMOVE | typeof WORKTREE_RENAME | typeof WORKTREE_RESOLVE_FOLDER | typeof WORKTREE_LINKED_TASKS | typeof TASK_ACQUIRE | typeof TASK_ACQUIRE_IN_WORKTREE | typeof TASK_SEARCH_FILES | typeof TASK_ADOPT_NATIVE_SESSION | typeof TASK_SEND | typeof TASK_RESOLVE_CONFIG_PREFERENCES | typeof TASK_SET_CONFIG_OPTION | typeof TASK_SET_TITLE | typeof TASK_CANCEL | typeof TASK_STOP_BACKGROUND_COMMAND | typeof TASK_STOP_SUBAGENT | typeof TASK_OPEN | typeof TASK_MARK_READ | typeof TASK_CHAT_PAGE | typeof TASK_LIST | typeof TASK_NAVIGATION_REFRESH | typeof TASK_NAVIGATION_LOAD_MORE | typeof NATIVE_SESSION_ARCHIVE | typeof NATIVE_SESSION_SET_TITLE | typeof NATIVE_SESSION_SET_PINNED | typeof NATIVE_SESSION_RESTORE | typeof TASK_RELEASE | typeof TASK_ARCHIVE | typeof TASK_RESTORE | typeof CLIENT_UPDATE_SHUTDOWN_PREPARE | typeof CLIENT_UPDATE_SHUTDOWN_COMMIT | typeof CLIENT_UPDATE_SHUTDOWN_ABORT | typeof DIAGNOSTICS_LIST_SUPPORT_EXPORT | typeof DIAGNOSTICS_CREATE_SUPPORT_EXPORT | typeof PROJECT_ADD | typeof PROJECT_RENAME | typeof PROJECT_REMOVE | typeof PROJECT_REFRESH | typeof TASK_QUEUE_APPEND | typeof TASK_QUEUE_REMOVE | typeof TASK_QUEUE_TAKE | typeof TASK_QUEUE_MOVE | typeof TASK_SET_PERMISSION_POLICY | typeof TASK_SET_PINNED | typeof TASK_CLOSE_PLAN | typeof TASK_TOOL_IMAGE_PREVIEW | typeof FILE_VIEWER_LIST_DIRECTORY | typeof FILE_VIEWER_SEARCH | typeof FILE_VIEWER_CHANGES | typeof FILE_VIEWER_DIFF | typeof FILE_VIEWER_OPEN | typeof FILE_VIEWER_OPEN_FROM_HANDLE | typeof FILE_VIEWER_REFRESH | typeof FILE_VIEWER_RELEASE | typeof TASK_COMPOSER_HISTORY | typeof SETTINGS_RESET_TASK_HISTORY | typeof NATIVE_SESSION_DELETE | typeof NATIVE_SESSION_FORK | typeof TASK_RELOAD_NATIVE_SESSION | typeof TASK_ARCHIVE_OLDER | typeof AGENT_CANCEL_AUTHENTICATE | typeof AGENT_LOGOUT | typeof AGENT_REFRESH_ACCOUNT_LIMITS | typeof TASK_QUEUE_RESUME | typeof DEVICES_CREATE_INVITE | typeof DEVICES_CANCEL_INVITE | typeof DEVICES_PREVIEW_JOIN_REQUEST | typeof DEVICES_APPROVE_JOIN_REQUEST | typeof DEVICES_REMOVE | typeof CLIENT_REPLACEMENT_IMPACT | typeof TASK_ATTENTION_WAIT | typeof TERMINALS_OPEN | typeof TERMINALS_ATTACH | typeof TERMINALS_CLOSE; export type RequestParamsByMethod = { [FILE_VIEWER_LIST_DIRECTORY]: ProjectFilesParams; [FILE_VIEWER_SEARCH]: ProjectFilesParams; @@ -1566,6 +1640,7 @@ export type RequestParamsByMethod = { [CLIENT_UPDATE_SHUTDOWN_PREPARE]: UpdateShutdownPrepareParams; [CLIENT_UPDATE_SHUTDOWN_COMMIT]: UpdateShutdownCommitParams; [CLIENT_UPDATE_SHUTDOWN_ABORT]: UpdateShutdownAbortParams; + [CLIENT_REPLACEMENT_IMPACT]: ClientReplacementImpactParams; [PENDING_REQUEST_RESOLVE]: PendingRequestResolveParams; [STATE_SUBSCRIBE]: StateSubscribeParams; [STATE_UNSUBSCRIBE]: StateUnsubscribeParams; @@ -1605,6 +1680,9 @@ export type RequestParamsByMethod = { [DEVICES_APPROVE_JOIN_REQUEST]: DevicesApproveJoinRequestParams; [DEVICES_REMOVE]: DevicesRemoveParams; [TASK_ATTENTION_WAIT]: TaskAttentionWaitParams; + [TERMINALS_OPEN]: TerminalsOpenParams; + [TERMINALS_ATTACH]: TerminalsAttachParams; + [TERMINALS_CLOSE]: TerminalsCloseParams; [ATTACHMENT_LIST_ROOTS]: AttachmentListRootsParams; [ATTACHMENT_LIST_DIRECTORY]: AttachmentListDirectoryParams; [ATTACHMENT_CREATE_FILE_REFERENCE]: AttachmentCreateFileReferenceParams; @@ -1688,6 +1766,7 @@ export type ResponseResultByMethod = { [CLIENT_UPDATE_SHUTDOWN_PREPARE]: UpdateShutdownPrepareResult; [CLIENT_UPDATE_SHUTDOWN_COMMIT]: UpdateShutdownCommitResult; [CLIENT_UPDATE_SHUTDOWN_ABORT]: UpdateShutdownAbortResult; + [CLIENT_REPLACEMENT_IMPACT]: ClientReplacementImpactResult; [PENDING_REQUEST_RESOLVE]: PendingRequestResolveResult; [STATE_SUBSCRIBE]: StateSubscribeResult; [STATE_UNSUBSCRIBE]: StateUnsubscribeResult; @@ -1727,6 +1806,9 @@ export type ResponseResultByMethod = { [DEVICES_APPROVE_JOIN_REQUEST]: DevicesApproveJoinRequestResult; [DEVICES_REMOVE]: DevicesRemoveResult; [TASK_ATTENTION_WAIT]: TaskAttentionWaitResult; + [TERMINALS_OPEN]: TerminalsOpenResult; + [TERMINALS_ATTACH]: TerminalsAttachResult; + [TERMINALS_CLOSE]: TerminalsCloseResult; [ATTACHMENT_LIST_ROOTS]: AttachmentListRootsResult; [ATTACHMENT_LIST_DIRECTORY]: AttachmentListDirectoryResult; [ATTACHMENT_CREATE_FILE_REFERENCE]: AttachmentCreateFileReferenceResult; @@ -1811,6 +1893,7 @@ export type ClientDetachRequest = TypedClientRequest; export type ClientUpdateShutdownPrepareRequest = TypedClientRequest; export type ClientUpdateShutdownCommitRequest = TypedClientRequest; export type ClientUpdateShutdownAbortRequest = TypedClientRequest; +export type ClientReplacementImpactRequest = TypedClientRequest; export type ClientCapabilitiesChangedRequest = TypedClientRequest; export type ClientInitializeResponse = ResponseEnvelope; export type ClientCapabilitiesChangedResponse = ResponseEnvelope; @@ -1819,6 +1902,7 @@ export type ClientDetachResponse = ResponseEnvelope; export type ClientUpdateShutdownPrepareResponse = ResponseEnvelope; export type ClientUpdateShutdownCommitResponse = ResponseEnvelope; export type ClientUpdateShutdownAbortResponse = ResponseEnvelope; +export type ClientReplacementImpactResponse = ResponseEnvelope; export type StateSubscribeResponse = ResponseEnvelope; export type StateUnsubscribeResponse = ResponseEnvelope; export type DiagnosticsGetRuntimeResponse = ResponseEnvelope; @@ -1918,4 +2002,4 @@ export type TypedServerRequest = ServerRequestEnv method: M; }; -export const APP_SERVER_PROTOCOL_VERSION: ProtocolVersion = { major: 2, minor: 2 }; +export const APP_SERVER_PROTOCOL_VERSION: ProtocolVersion = { major: 2, minor: 3 }; diff --git a/packages/app-server-client/src/stateIngestionSnapshots.ts b/packages/app-server-client/src/stateIngestionSnapshots.ts index 957743e3..a4b1d285 100644 --- a/packages/app-server-client/src/stateIngestionSnapshots.ts +++ b/packages/app-server-client/src/stateIngestionSnapshots.ts @@ -35,6 +35,10 @@ export function updateSubscriptionSnapshot( return payload.kind === "deviceCollectionUpdated" ? changed({ kind: "devices", devices: payload.devices }) : unchanged(snapshot); + case "terminals": + return payload.kind === "terminalCollectionUpdated" + ? changed({ kind: "terminals", terminals: payload.terminals }) + : unchanged(snapshot); case "settings": return unchanged(snapshot); case "taskNavigation": @@ -123,6 +127,7 @@ function updateFromClientSnapshot( case "subagentHistory": return unchanged(snapshot); case "devices": + case "terminals": case "toolDetail": case "worktreeRepository": return unchanged(snapshot); diff --git a/packages/app-server-client/src/subscriptionScope.ts b/packages/app-server-client/src/subscriptionScope.ts index dab39054..5c84d340 100644 --- a/packages/app-server-client/src/subscriptionScope.ts +++ b/packages/app-server-client/src/subscriptionScope.ts @@ -22,6 +22,7 @@ export function subscriptionScopesEqual(left: SubscriptionScope, right: Subscrip case "projects": case "agents": case "devices": + case "terminals": return right.kind === left.kind; case "settings": return right.kind === "settings" && normalizeOptional(left.section) === normalizeOptional(right.section); @@ -100,6 +101,8 @@ function payloadMatchesSubscriptionScope(scope: SubscriptionScope, payload: AppS return payload.kind === "snapshotReplaced" || payload.kind === "agentCollectionUpdated"; case "devices": return payload.kind === "snapshotReplaced" || payload.kind === "deviceCollectionUpdated"; + case "terminals": + return payload.kind === "snapshotReplaced" || payload.kind === "terminalCollectionUpdated"; case "settings": return payload.kind === "snapshotReplaced"; case "taskNavigation": diff --git a/packages/app-shell-contracts/src/webview/messages.ts b/packages/app-shell-contracts/src/webview/messages.ts index 6e74a141..988de839 100644 --- a/packages/app-shell-contracts/src/webview/messages.ts +++ b/packages/app-shell-contracts/src/webview/messages.ts @@ -56,6 +56,7 @@ export type WebviewToHostMessage = | { type: "surface.updateTaskTitle"; payload: { task_id: string; title: string } } | { type: "shell.openExternal"; payload: { url: string } } | { type: "shell.reload" } + | { type: "shell.terminal.open"; payload: { folder: string } } | { type: "shell.clipboard.writeText"; payload: { requestId: string; text: string } } | { type: "supportExport.save"; payload: { requestId: string; fileHandleId: string; label: string; clientInstanceId: string } } | { type: "attachment.pickFiles"; payload: { requestId: string; taskId: string } } diff --git a/packages/frontend/src/components/AppSurfaces.test.tsx b/packages/frontend/src/components/AppSurfaces.test.tsx index 221402b6..4e282143 100644 --- a/packages/frontend/src/components/AppSurfaces.test.tsx +++ b/packages/frontend/src/components/AppSurfaces.test.tsx @@ -1610,6 +1610,7 @@ function controllerFor(surface: AppController["bootstrap"]["surface"]): TestCont createInvite: vi.fn(), previewJoinRequest: vi.fn(), remove: vi.fn(), + replacementImpact: vi.fn(), }, getMcpServerDetails: vi.fn(), getSkillDetails: vi.fn(), diff --git a/packages/frontend/src/components/AppSurfaces.tsx b/packages/frontend/src/components/AppSurfaces.tsx index bcac5d5a..b07a7991 100644 --- a/packages/frontend/src/components/AppSurfaces.tsx +++ b/packages/frontend/src/components/AppSurfaces.tsx @@ -8,7 +8,7 @@ import { SettingsView } from "./settings/SettingsView"; import { TaskPermissionPolicyControl } from "./TaskPermissionPolicyControl"; import { taskStatusLabel } from "./TaskHeader"; import type { AppController } from "./appController"; -import { useMobileNavigation } from "./useMobileNavigation"; +import { isMobileWebViewport, mobileNavigationFocusableElements, useMobileNavigation } from "./useMobileNavigation"; import { useBackNavigation } from "./useBackNavigation"; import { useInputModality } from "./useInputModality"; import { useWebTaskNotifications } from "./useWebTaskNotifications"; @@ -16,10 +16,12 @@ import { updateTaskSurfaceTitle } from "../services/hostBridge"; import { ProjectFolderDialog } from "./ProjectFolderDialog"; import { ProjectRemoveDialog } from "./ProjectRemoveDialog"; import { currentFrontendShell, type DesktopUpdateCapability } from "../services/frontendShell"; +import { TerminalPageHost } from "./terminals/TerminalPageHost"; +import { TerminalSurfaceContext, useTerminalPage } from "./terminals/terminalSurface"; import { computerLink } from "./ComputerSwitcher"; import { ComputerSwitchConfirm } from "./ComputerSwitchConfirm"; import { useComputerPlace } from "./useComputerPlace"; -import { useComputers } from "../services/computers"; +import { currentComputer, useComputers } from "../services/computers"; import { computerSwitch } from "../services/computerSwitch"; export function AppSurfaces({ controller }: { controller: AppController }) { @@ -111,6 +113,9 @@ export function AppSurfaces({ controller }: { controller: AppController }) { ? bootstrap.taskId : activeNavigationTaskId; const mobileNavigation = useMobileNavigation(isWebWorkbench && mobileLayoutActive); + // The in-app Terminal page needs a socket route; shells without one (VS Code) omit the entries. + // The Project menu opens the page from the phone's navigation drawer, which then gets out of the way. + const terminalPage = useTerminalPage(`${bootstrap.surface}:${sidebarActiveTaskId ?? ""}`, view.terminals?.terminals, () => mobileNavigation.setOpen(false)); const mobileNavigationOpen = mobileNavigation.open; const taskSurfaceModel = primaryTaskSurfaceModel(controller); const mobilePermissionTask = isWebWorkbench ? taskSurfaceModel.renderableTaskSnapshot : undefined; @@ -609,10 +614,12 @@ export function AppSurfaces({ controller }: { controller: AppController }) { /> ); return ( + + {appServerError ? ( ) : ( @@ -724,6 +742,7 @@ export function AppSurfaces({ controller }: { controller: AppController }) { )} + ); } @@ -766,22 +785,6 @@ function useDesktopUpdateSnapshot( ); } -function isMobileWebViewport() { - if (typeof window === "undefined") return false; - if (typeof window.matchMedia === "function") { - return window.matchMedia("(max-width: 760px)").matches; - } - return window.innerWidth <= 760; -} - -function mobileNavigationFocusableElements(root: HTMLElement) { - return Array.from( - root.querySelectorAll( - ".mobile-workbench-bar button, .sidebar button:not(:disabled), .sidebar input:not(:disabled)", - ), - ).filter((element) => element.offsetParent !== null); -} - function AppServerErrorView({ message }: { message: string }) { return (
) : null} + {(terminalSurface || nativeTerminal) && group.workspaceRoot ? ( + + ) : null} {canManageWorktrees && onManageWorktrees ? : null} {onRenameProject ? : null} {onRemoveProject ? : null} diff --git a/packages/frontend/src/components/TaskView.headerActions.dom.test.tsx b/packages/frontend/src/components/TaskView.headerActions.dom.test.tsx index 7e9e6ff7..448468d8 100644 --- a/packages/frontend/src/components/TaskView.headerActions.dom.test.tsx +++ b/packages/frontend/src/components/TaskView.headerActions.dom.test.tsx @@ -5,6 +5,7 @@ import { createRoot, type Root } from "react-dom/client"; import { afterEach, expect, it, vi } from "vitest"; import type { ChatMessage, TaskSnapshot } from "@openaide/app-shell-contracts"; import { TaskView } from "./TaskView"; +import { TerminalSurfaceContext } from "./terminals/terminalSurface"; (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; @@ -32,6 +33,28 @@ it("keeps one message picker in the mobile header across task switches", () => { expect(header.childElementCount).toBe(0); }); +it("opens the Task's Project Terminals from the header, starting new ones in its Workspace", () => { + // jsdom has no layout; the message picker scrolls its marker into view on mount. + Element.prototype.scrollIntoView ??= vi.fn(); + const host = document.createElement("div"); + document.body.append(host); + root = createRoot(host); + const openPage = vi.fn(); + const task = snapshot("task-a"); + task.task.project_id = "project-1"; + const terminals = [{ terminalId: "t1", projectId: "project-1" as never, folder: "/w", status: "busy" as const, openedAt: 1 }]; + + act(() => root!.render( + + + , + )); + const entry = host.querySelector('[aria-label="Terminal, 1 open"]'); + act(() => entry!.click()); + + expect(openPage).toHaveBeenCalledWith({ projectId: "project-1", taskId: "task-a" }); +}); + function taskViewProps(taskSnapshot: TaskSnapshot) { return { backendReady: true, diff --git a/packages/frontend/src/components/TaskView.tsx b/packages/frontend/src/components/TaskView.tsx index 7beb5e55..2e76d341 100644 --- a/packages/frontend/src/components/TaskView.tsx +++ b/packages/frontend/src/components/TaskView.tsx @@ -39,6 +39,7 @@ import { useTaskHistoryView } from "./useTaskHistoryView"; import { AgentFileOpenContext } from "./agentFileOpen"; import { ProjectFileWorkspace } from "./ProjectFileWorkspace"; import { ProjectFilesButton } from "./ProjectFilesButton"; +import { TerminalButton } from "./terminals/TerminalButton"; import { ComposerWithContextUsage } from "./ContextUsageIndicator"; import { AgentPlanView, resetAgentPlanDisclosure } from "./AgentPlan"; import { TaskMessageQueueView } from "./TaskMessageQueue"; @@ -539,6 +540,12 @@ export function TaskView({ {completedPlanSteps}/{visiblePlan.entries.length} ) : null} + {fileViewerEnabled && setProjectFilesOpen(true)} target={headerActionsTarget} />} diff --git a/packages/frontend/src/components/appController.test.tsx b/packages/frontend/src/components/appController.test.tsx index a86a894c..ee5fa520 100644 --- a/packages/frontend/src/components/appController.test.tsx +++ b/packages/frontend/src/components/appController.test.tsx @@ -4489,6 +4489,13 @@ function nonTaskSubscriptionSnapshot( }, }; } + if (scope?.kind === "terminals") { + return { + cursor: cursor as never, + scope, + snapshot: { kind: "terminals" as const, terminals: { terminals: [] } }, + }; + } throw new Error(`unexpected subscription scope: ${scope?.kind ?? "missing"}`); } diff --git a/packages/frontend/src/components/appController.ts b/packages/frontend/src/components/appController.ts index 0d7b234f..27f5673d 100644 --- a/packages/frontend/src/components/appController.ts +++ b/packages/frontend/src/components/appController.ts @@ -56,6 +56,7 @@ import { useNewTaskWorkspace } from "./useNewTaskWorkspace"; import { useTaskWorkspace } from "./useTaskWorkspace"; import type { NewTaskViewIntents, NewTaskViewState } from "./NewTaskView"; import type { TaskViewIntents } from "./TaskView"; +import { createTerminalIntents, type TerminalIntents } from "../intents/terminalIntents"; import { mapProtocolTaskSummary } from "../state/appServerProtocolMapping"; import { useNativeSessionRouteLifecycle } from "./useNativeSessionRouteLifecycle"; import { initialTaskNavigationRowsPerProject } from "../state/taskNavigationPolicy"; @@ -105,6 +106,7 @@ export type AppControllerView = { toolDetails: AppState["toolDetails"]; }; settings: AppState["settings"]; + terminals?: AppState["terminals"]; }; /** Render-ready state and user-intent operations consumed by App surfaces. */ @@ -129,6 +131,8 @@ export type AppController = { rename: (projectId: string, label: string) => Promise; }; task: TaskViewIntents; + /** Absent until the App Server session exists. */ + terminals?: TerminalIntents; }; preferences: AppPreferencesRecord; retryTaskOpen: () => void; @@ -342,6 +346,10 @@ export function useAppController(options: AppControllerOptions = {}): AppControl const workspaceCapability = useMemo(() => getWorkspaceCapability(), []); const backendConnection = options.backendConnection ?? defaultBackendConnection; const request = backendConnection?.request; + const terminalIntents = useMemo( + () => (request ? createTerminalIntents({ request }) : undefined), + [request], + ); const core = useAppControllerCore({ backendConnection }); const { createSnapshotRequestId: _createSnapshotRequestId, dispatch, newTaskSnapshot, state, ...renderState } = core; const routedTaskId = state.snapshot?.task.task_id; @@ -564,6 +572,7 @@ export function useAppController(options: AppControllerOptions = {}): AppControl return (await request(PROJECT_REFRESH, { projectId: projectId as ProjectId })).project; }, }, + terminals: terminalIntents, task: { refreshWorkspace: async () => { const task = decoratedSnapshot?.task ?? decoratedActiveTask; @@ -626,6 +635,7 @@ export function useAppController(options: AppControllerOptions = {}): AppControl toolDetails: state.toolDetails, }, settings: state.settings, + terminals: state.terminals, }, }; } diff --git a/packages/frontend/src/components/appControllerBackendLifecycle.ts b/packages/frontend/src/components/appControllerBackendLifecycle.ts index 5e79a84e..cc256882 100644 --- a/packages/frontend/src/components/appControllerBackendLifecycle.ts +++ b/packages/frontend/src/components/appControllerBackendLifecycle.ts @@ -393,6 +393,13 @@ export function useAppControllerBackendLifecycle({ dispatch: dispatchForCurrentReplica, scope: { kind: "devices" }, })); + // Terminals never gate readiness either: only the Terminal page and quit warnings read them. + stopSubscriptions.push(startAppServerStateSubscription({ + backendConnection: subscriptionConnection, + context: subscriptionContext, + dispatch: dispatchForCurrentReplica, + scope: { kind: "terminals" }, + })); for (const project of result.snapshot.projects?.projects ?? []) { if (!project.worktreeRepositoryId) continue; stopSubscriptions.push(startAppServerStateSubscription({ diff --git a/packages/frontend/src/components/settings/GeneralSection.tsx b/packages/frontend/src/components/settings/GeneralSection.tsx index d56d65fb..9c0f406b 100644 --- a/packages/frontend/src/components/settings/GeneralSection.tsx +++ b/packages/frontend/src/components/settings/GeneralSection.tsx @@ -19,6 +19,7 @@ import { useUpdateGroup } from "./updateSettings"; export type GeneralSectionProps = { appearance?: FrontendShellAppearance; + replacementImpact?: () => Promise; desktopNotifications?: DesktopNotificationSettings; onSetComposerSubmitShortcut: (shortcut: ComposerSubmitShortcut) => void; onSetDesktopNotifications?: (enabled: boolean) => void | Promise; @@ -34,11 +35,12 @@ export function useGeneralSection({ onSetDesktopNotifications, phone, preferences, + replacementImpact, }: GeneralSectionProps): SettingsSection { const shell = currentFrontendShell(); // While a paired computer is in use, its own runtime applies and this choice has no effect. const runtime = useRuntimeGroup(currentComputer(shell?.computers?.snapshot()) ? undefined : shell?.desktopRuntime); - const updates = useUpdateGroup(shell?.desktopUpdates); + const updates = useUpdateGroup(shell?.desktopUpdates, replacementImpact); const enterSends = preferences.composer_submit_shortcut === "enter"; const everyday: SettingsRow[] = []; if (!usesMobileComposerBehavior()) { diff --git a/packages/frontend/src/components/settings/RemoteSection.tsx b/packages/frontend/src/components/settings/RemoteSection.tsx index 8afa467e..259655a8 100644 --- a/packages/frontend/src/components/settings/RemoteSection.tsx +++ b/packages/frontend/src/components/settings/RemoteSection.tsx @@ -1,14 +1,16 @@ import type { DeviceCollectionSnapshot, RemoteDeviceSummary } from "@openaide/app-server-client"; -import { Laptop, Monitor, Plus, QrCode, Smartphone, Wifi } from "lucide-react"; +import { Laptop, Monitor, Plus, QrCode, Server, Smartphone, Wifi } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import type { RemoteDeviceIntents } from "../../intents/remoteDeviceIntents"; -import { computerName, currentComputer, useComputers, type ComputersCapability, type ComputersSnapshot, type ShellComputer } from "../../services/computers"; +import { computerName, currentComputer, useComputers, type ComputersCapability, type ComputersSnapshot, type ShellComputer, type SshComputerStatus } from "../../services/computers"; import { computerSwitch, useComputerSwitch } from "../../services/computerSwitch"; +import type { PhoneSshComputer } from "../../services/connectionSettings"; import { currentFrontendShell } from "../../services/frontendShell"; import { PopupDialog } from "../Popup"; import { InviteDialog, JoinDialog } from "./devicePairingDialogs"; import type { PhoneConnection } from "./phoneConnection"; import { settingsInfo } from "./settingsInfo"; +import { AddSshComputerDialog, UpdateSshComputerDialog, useSshStatus } from "./sshComputers"; import { errorSentence, SettingsButton, SettingsChoice, SettingsConfirm, SettingsInlineError, SettingsNotice } from "./settingsKit"; import type { SettingsGroup, SettingsRow, SettingsSection } from "./settingsModel"; @@ -21,6 +23,12 @@ export type RemoteSectionProps = { type Dialog = | { kind: "pair" } | { kind: "forget"; computer: ShellComputer } + | { kind: "add-ssh" } + | { kind: "update-ssh" } + | { kind: "stop-ssh"; computer: ShellComputer } + | { kind: "stop-phone-ssh"; computer: PhoneSshComputer } + | { kind: "forget-phone-ssh"; computer: PhoneSshComputer } + | { kind: "update-phone-ssh" } | { kind: "invite" } | { kind: "join" } | { kind: "remove"; device: RemoteDeviceSummary }; @@ -32,20 +40,30 @@ type Dialog = export function useRemoteSection({ deviceIntents, devices, phone }: RemoteSectionProps): SettingsSection | undefined { const capability = currentFrontendShell()?.computers; const computers = useSettingsComputers(capability); + const ssh = capability?.ssh; + const sshStatus = useSshStatus(ssh); + const [sshNotice, setSshNotice] = useState(); const [dialog, setDialog] = useState(); const close = () => setDialog(undefined); if (!capability && !deviceIntents) return undefined; const inUse = currentComputer(computers.snapshot); - const remoteName = inUse ? computerName(inUse) : undefined; + const phoneSsh = phone?.state?.sshComputers?.find((computer) => computer.current); + const remoteName = inUse ? computerName(inUse) : phoneSsh?.name; const device = computers.snapshot?.device === "phone" || phone ? "phone" : "computer"; const here = `this ${device}`; const groups: SettingsGroup[] = []; if (capability) { - groups.push(computersGroup(computers, phone, () => { - if (capability.pair) setDialog({ kind: "pair" }); - else void computers.openPairing(); - }, (computer) => setDialog({ kind: "forget", computer }))); + groups.push(computersGroup(computers, phone, { + onPair: () => { + if (capability.pair) setDialog({ kind: "pair" }); + else void computers.openPairing(); + }, + open: setDialog, + canAddSsh: Boolean(ssh), + sshStatus, + sshNotice, + })); } if (deviceIntents) { // The App Server names itself; a browser tab on another machine is not "this computer". @@ -64,7 +82,9 @@ export function useRemoteSection({ deviceIntents, devices, phone }: RemoteSectio {dialog.kind === "forget" ? ( computers.forget(dialog.computer.id)} @@ -72,6 +92,65 @@ export function useRemoteSection({ deviceIntents, devices, phone }: RemoteSectio title={`Forget ${computerName(dialog.computer)}?`} /> ) : null} + {dialog.kind === "add-ssh" && ssh ? : null} + {dialog.kind === "update-ssh" && ssh && sshStatus ? ( + ssh.update()} + status={sshStatus} + /> + ) : null} + {dialog.kind === "stop-ssh" && ssh ? ( + { + const outcome = await ssh.stop(dialog.computer.id); + setSshNotice(outcome === "notRunning" + ? `OpenAIDE was not kept running on ${dialog.computer.name}.` + : outcome === "stillRunning" + ? `OpenAIDE on ${dialog.computer.name} did not stop yet. Try again in a moment.` + : `OpenAIDE on ${dialog.computer.name} stops once no app is connected to it.`); + }} + pendingLabel="Stopping…" + title={`Stop OpenAIDE on ${dialog.computer.name}?`} + /> + ) : null} + {dialog.kind === "stop-phone-ssh" && phone ? ( + phone.execute({ action: "stop_ssh", id: dialog.computer.id })} + pendingLabel="Stopping…" + title={`Stop OpenAIDE on ${dialog.computer.name}?`} + /> + ) : null} + {dialog.kind === "forget-phone-ssh" && phone ? ( + phone.execute({ action: "forget_ssh", id: dialog.computer.id })} + pendingLabel="Forgetting…" + title={`Forget ${dialog.computer.name}?`} + /> + ) : null} + {dialog.kind === "update-phone-ssh" && phone?.state?.ssh ? ( + phone.execute({ action: "update_ssh" })} + status={phone.state.ssh} + /> + ) : null} {dialog.kind === "invite" && deviceIntents ? : null} {dialog.kind === "join" && deviceIntents ? : null} {dialog.kind === "remove" && deviceIntents ? ( @@ -146,15 +225,25 @@ function useSettingsComputers(capability: ComputersCapability | undefined): Sett }; } -function computersGroup( - computers: SettingsComputers, - phone: PhoneConnection | undefined, - onPair: () => void, - onForget: (computer: ShellComputer) => void, -): SettingsGroup { +type ComputersView = { + onPair(): void; + open(dialog: Dialog): void; + /** The shell can start the App Server on SSH Computers. */ + canAddSsh: boolean; + /** The shell's report on the SSH Computer in use. */ + sshStatus: SshComputerStatus | undefined; + /** The outcome of the last Stop. */ + sshNotice: string | undefined; +}; + +function computersGroup(computers: SettingsComputers, phone: PhoneConnection | undefined, view: ComputersView): SettingsGroup { const snapshot = computers.snapshot; const list = snapshot?.computers ?? []; - const remote = list.some((computer) => computer.current); + // TODO(ssh-computers): the phone keeps its SSH Computers in its Settings state and + // switches to them outside the computer switch (ADR-0070); list them with the + // other computers, so the Task navigation offers them too, once the phone does. + const phoneSsh = phone?.state?.sshComputers ?? []; + const remote = list.some((computer) => computer.current) || phoneSsh.some((computer) => computer.current); const busy = computers.switching !== undefined || Boolean(phone?.busy); const onPhone = snapshot?.device === "phone"; const here = onPhone ? "This phone" : "This computer"; @@ -191,14 +280,23 @@ function computersGroup( rows.push({ id: `computer-${computer.id}`, label: name, - description: "Paired computer", - keywords: "computer remote connect switch pc", - leading: